Files
shelfmark/tests/core/test_health_endpoint.py
T
splitsec2 ef40a38546 fix: report a stopped download coordinator in /api/health (#1568)
If the download coordinator thread dies, every task sits at "Queued" and
nothing says why. The rest of the app keeps answering, `/api/health`
stays green and the container looks fine. #823 and #1166 were both that,
and the loop's `except Exception` cannot catch everything that can end a
thread.

`/api/health` now reports it. The response gets a `coordinator` field,
`running` or `stopped`, and when it is stopped the status is `error` and
the HTTP code is 503. The Docker healthcheck in the Dockerfile and the
one in the readme both use `curl -f`, so the container shows as
unhealthy and anything watching health (Uptime Kuma, autoheal) can act
on it. It needs no login, same as before, and shows no queue contents.

That 503 is the one decision here. Keeping the 200 and only adding a
`degraded` entry would not trip a status-code health check, which is the
whole point, so I went with 503. Easy to flip if you would rather not.

The orchestrator gets a small `coordinator_running()` that asks whether
the thread is alive. `start()` already knows how to restart a dead
thread, so restarting it automatically would be the true fix, but I
would rather the failure be visible first and decide on that separately.

Tests: three for the route (ok, 503, no login needed) and three for
`coordinator_running` (never started, alive, dead). They fail without
the change. One existing test hits `/api/health` through an app that
never starts the coordinator, so it now patches that. The readme's
health section has a sentence about the 503. Full suite passes, ruff and
basedpyright are clean.
2026-10-24 17:12:58 -06:00

79 lines
2.5 KiB
Python

"""/api/health must say so when the download coordinator is not running."""
from __future__ import annotations
import importlib
import threading
from unittest.mock import patch
import pytest
from shelfmark.download import orchestrator
@pytest.fixture(scope="module")
def main_module():
"""Import `shelfmark.main` with background startup disabled."""
with patch("shelfmark.download.orchestrator.start"):
import shelfmark.main as main
importlib.reload(main)
return main
@pytest.fixture
def client(main_module):
return main_module.app.test_client()
def test_health_is_ok_while_the_coordinator_runs(main_module, client):
with patch.object(main_module.backend, "coordinator_running", return_value=True):
resp = client.get("/api/health")
assert resp.status_code == 200
assert resp.get_json()["status"] == "ok"
assert resp.get_json()["coordinator"] == "running"
def test_health_fails_when_the_coordinator_is_gone(main_module, client):
with patch.object(main_module.backend, "coordinator_running", return_value=False):
resp = client.get("/api/health")
assert resp.status_code == 503
assert resp.get_json()["status"] == "error"
assert resp.get_json()["coordinator"] == "stopped"
def test_health_needs_no_login(main_module, client):
with (
patch.object(main_module, "get_auth_mode", return_value="builtin"),
patch.object(main_module.backend, "coordinator_running", return_value=True),
):
resp = client.get("/api/health")
assert resp.status_code == 200
class TestCoordinatorRunning:
def test_false_before_it_was_ever_started(self, monkeypatch):
monkeypatch.setattr(orchestrator, "_coordinator_thread", None)
assert orchestrator.coordinator_running() is False
def test_true_while_the_thread_is_alive(self, monkeypatch):
release = threading.Event()
thread = threading.Thread(target=release.wait, daemon=True)
thread.start()
monkeypatch.setattr(orchestrator, "_coordinator_thread", thread)
try:
assert orchestrator.coordinator_running() is True
finally:
release.set()
thread.join(timeout=2)
def test_false_once_the_thread_has_died(self, monkeypatch):
thread = threading.Thread(target=lambda: None, daemon=True)
thread.start()
thread.join(timeout=2)
monkeypatch.setattr(orchestrator, "_coordinator_thread", thread)
assert orchestrator.coordinator_running() is False