Fix health check endpoint to return 503 when fail2ban is offline

The health check endpoint now properly indicates service unavailability:
- Returns HTTP 200 when fail2ban is online
- Returns HTTP 503 when fail2ban is offline

This allows Docker and other orchestration tools to correctly detect when
fail2ban is unreachable and automatically restart the backend container,
preventing the situation where Docker treats the container as healthy
despite fail2ban being down.

Changes:
- Update GET /api/health to return 503 on fail2ban offline
- Return appropriate JSON response bodies for each state
- Update tests to verify both online (200) and offline (503) scenarios
- Update Dockerfile HEALTHCHECK documentation
- Add Health Checks section to Deployment.md documentation

All tests pass with 100% coverage on health.py.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-30 21:56:42 +02:00
parent 52f237d5d4
commit 94d6352d1d
5 changed files with 60 additions and 72 deletions

View File

@@ -16,19 +16,28 @@ router: APIRouter = APIRouter(prefix="/api", tags=["Health"])
@router.get("/health", summary="Application health check")
async def health_check(server_status: ServerStatusDep) -> JSONResponse:
"""Return 200 with application and fail2ban status.
"""Return application and fail2ban status.
HTTP 200 is always returned so Docker health checks do not restart the
backend container when fail2ban is temporarily offline. The
``fail2ban`` field in the body indicates the daemon's current state.
Returns HTTP 200 if fail2ban is online, HTTP 503 if offline.
Docker health checks interpret 503 as unhealthy and restart the container
if fail2ban remains unreachable, ensuring the backend only runs when
fail2ban is available.
Args:
server_status: Injected cached server status snapshot.
Returns:
A JSON object with ``{"status": "ok", "fail2ban": "online"|"offline"}``.
HTTP 200 with ``{"status": "ok", "fail2ban": "online"}`` if healthy,
or HTTP 503 with ``{"status": "unavailable", "fail2ban": "offline"}``
if fail2ban is unreachable.
"""
return JSONResponse(content={
"status": "ok",
"fail2ban": "online" if server_status.online else "offline",
})
if not server_status.online:
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content={"status": "unavailable", "fail2ban": "offline"},
)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={"status": "ok", "fail2ban": "online"},
)

View File

@@ -7,19 +7,39 @@ from app.models.server import ServerStatus
@pytest.mark.asyncio
async def test_health_check_returns_200(client: AsyncClient) -> None:
"""``GET /api/health`` must return HTTP 200."""
async def test_health_check_returns_200_when_online(client: AsyncClient) -> None:
"""``GET /api/health`` must return HTTP 200 when fail2ban is online."""
client._transport.app.state.server_status = ServerStatus(online=True)
response = await client.get("/api/health")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_health_check_returns_ok_status(client: AsyncClient) -> None:
"""``GET /api/health`` must contain ``status: ok`` and a ``fail2ban`` field."""
async def test_health_check_returns_503_when_offline(client: AsyncClient) -> None:
"""``GET /api/health`` must return HTTP 503 when fail2ban is offline."""
client._transport.app.state.server_status = ServerStatus(online=False)
response = await client.get("/api/health")
assert response.status_code == 503
@pytest.mark.asyncio
async def test_health_check_returns_ok_status_when_online(client: AsyncClient) -> None:
"""``GET /api/health`` must contain ``status: ok`` when fail2ban is online."""
client._transport.app.state.server_status = ServerStatus(online=True)
response = await client.get("/api/health")
data: dict[str, str] = response.json()
assert data["status"] == "ok"
assert data["fail2ban"] in ("online", "offline")
assert data["fail2ban"] == "online"
@pytest.mark.asyncio
async def test_health_check_returns_unavailable_when_offline(client: AsyncClient) -> None:
"""``GET /api/health`` must contain ``status: unavailable`` when fail2ban is offline."""
client._transport.app.state.server_status = ServerStatus(online=False)
response = await client.get("/api/health")
data: dict[str, str] = response.json()
assert data["status"] == "unavailable"
assert data["fail2ban"] == "offline"
@pytest.mark.asyncio
@@ -28,13 +48,3 @@ async def test_health_check_content_type_is_json(client: AsyncClient) -> None:
response = await client.get("/api/health")
assert "application/json" in response.headers.get("content-type", "")
@pytest.mark.asyncio
async def test_health_check_respects_cached_server_status(client: AsyncClient) -> None:
"""The health response should reflect the injected cached server status."""
client._transport.app.state.server_status = ServerStatus(online=True)
response = await client.get("/api/health")
assert response.status_code == 200
assert response.json()["fail2ban"] == "online"