Skip to content

Commit 9fb20a9

Browse files
bbertuccclaude
andauthored
fix(health): make /health/ready a strict readiness probe (#133)
The /health/ready endpoint previously hardcoded a 200 response, which meant orchestrators using it as a readiness probe never saw the api report unready even when document processing was broken. /health itself was tolerant by design (docling-serve cold-start takes minutes; failing liveness during that window caused restart loops), but that left no endpoint that returned 503 when docling-serve was permanently down — so ALBs and k8s pods would keep routing traffic to instances that couldn't do their job. Now: - /health remains tolerant. 200 healthy/degraded when core stores (Redis, S3, queue) are up, 503 only when a core store is down. Stays appropriate for liveness probes that should tolerate transient docling-serve outages. - /health/ready becomes strict. Returns 200 only when every dependency the pipeline needs (Redis, S3, queue, docling-serve) is reachable; 503 the moment any of them isn't. Suitable for orchestrator readiness probes (Kubernetes readinessProbe, ECS ALB target groups). - Module docstring + both endpoint docstrings explain the deliberate split so operators know which one to wire up where. - The stale "circuit breaker handles it at request level" comment is gone; the retry behaviour is real but only absorbs transient outages, not permanent ones — which is exactly what /health/ready now surfaces. Integration tests cover all four states for each endpoint. Added the pytestmark = pytest.mark.integration marker that was missing on the file (the original tests were being silently deselected by make test-integration's -m integration filter). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 339df13 commit 9fb20a9

2 files changed

Lines changed: 152 additions & 88 deletions

File tree

src/api/health.py

Lines changed: 75 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,24 @@
1-
"""Health check endpoints."""
1+
"""Health check endpoints.
2+
3+
Two endpoints with deliberately different contracts:
4+
5+
* ``GET /health`` is **tolerant**. It returns 200 with status
6+
``healthy``/``degraded`` when the api process is serving traffic and the
7+
core stores (Redis, S3) are reachable, even if docling-serve is briefly
8+
unavailable. The docling-serve client has retry + a circuit breaker for
9+
short outages, and CPU model load at boot takes a few minutes — failing
10+
this endpoint during that window would cause restart loops. Returns 503
11+
only when a core store is gone.
12+
13+
* ``GET /health/ready`` is **strict**. It returns 503 the moment *any*
14+
dependency the request path needs is unhealthy, including docling-serve.
15+
Wire orchestrator readiness probes (Kubernetes readinessProbe, ECS ALB
16+
target groups) here when you want traffic to stop the instant the
17+
pipeline can't process documents end to end.
18+
19+
Pair the two for the conventional k8s split: ``/health`` for liveness,
20+
``/health/ready`` for readiness.
21+
"""
222

323
from typing import Any
424

@@ -10,64 +30,82 @@
1030
router = APIRouter(prefix="/health", tags=["Health"])
1131

1232

13-
@router.get("")
14-
async def health_check(
15-
storage: StorageService = Depends(get_storage_service),
16-
queue: QueueService = Depends(get_queue_service)
33+
async def _collect_checks(
34+
storage: StorageService, queue: QueueService
1735
) -> dict[str, Any]:
18-
"""
19-
Health check endpoint for container orchestration.
20-
21-
Checks Redis, S3, and queue connectivity.
36+
"""Run every dependency check and return a structured result.
2237
23-
Args:
24-
storage: Storage service (injected)
25-
queue: Queue service (injected)
26-
27-
Returns:
28-
Health status with detailed checks
38+
Shared by both endpoints so they cannot drift in what they probe.
2939
"""
30-
31-
# Check docling-serve health
3240
try:
3341
from ..services.docling_serve_client import get_docling_client
3442
docling_client = get_docling_client()
3543
docling_healthy = await docling_client.check_health()
3644
except RuntimeError:
45+
# Client not yet initialised (boot race)
3746
docling_healthy = False
3847

39-
checks = {
48+
return {
4049
"redis": await queue.check_redis_connection(),
4150
"s3": await storage.check_s3_access(),
4251
"queue_depth": await queue.check_queue_depth(),
4352
"docling_serve": docling_healthy,
4453
}
4554

46-
# Core checks: Redis, S3, queue must pass
47-
# docling_serve is non-fatal — circuit breaker handles it at request level,
48-
# and it takes ~2min to load models at boot (would cause ECS restart loops)
55+
56+
@router.get("")
57+
async def health_check(
58+
storage: StorageService = Depends(get_storage_service),
59+
queue: QueueService = Depends(get_queue_service)
60+
) -> dict[str, Any]:
61+
"""Tolerant health endpoint suitable for dashboards and liveness probes.
62+
63+
Returns 200 ``healthy`` when every check passes, 200 ``degraded`` when
64+
only docling-serve is down (boot warmup or transient outage that retry
65+
+ circuit-breaker can absorb), and 503 ``unhealthy`` when a core store
66+
(Redis, S3, queue) is unreachable.
67+
68+
Use ``/health/ready`` for an unforgiving readiness probe.
69+
"""
70+
checks = await _collect_checks(storage, queue)
4971
core_healthy = checks["redis"] and checks["s3"] and checks["queue_depth"] >= 0
5072
if core_healthy:
5173
return {
52-
"status": "healthy" if docling_healthy else "degraded",
53-
"checks": checks
74+
"status": "healthy" if checks["docling_serve"] else "degraded",
75+
"checks": checks,
5476
}
55-
else:
56-
raise HTTPException(
57-
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
58-
detail={
59-
"status": "unhealthy",
60-
"checks": checks
61-
}
62-
)
77+
raise HTTPException(
78+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
79+
detail={"status": "unhealthy", "checks": checks},
80+
)
6381

6482

6583
@router.get("/ready")
66-
async def readiness_check() -> dict[str, str]:
67-
"""
68-
Readiness check for Kubernetes/orchestration.
84+
async def readiness_check(
85+
storage: StorageService = Depends(get_storage_service),
86+
queue: QueueService = Depends(get_queue_service)
87+
) -> dict[str, Any]:
88+
"""Strict readiness probe for orchestrators.
89+
90+
Returns 200 ``ready`` only when every dependency the pipeline needs is
91+
reachable: Redis, S3, queue, and docling-serve. Any failure returns
92+
503 so the orchestrator stops routing traffic until end-to-end
93+
document processing can succeed again.
6994
70-
Returns:
71-
Ready status
95+
Configure Kubernetes ``readinessProbe`` or ECS ALB target groups
96+
against this endpoint when you want traffic gated on full pipeline
97+
health. Use ``/health`` (tolerant) for liveness alongside.
7298
"""
73-
return {"status": "ready"}
99+
checks = await _collect_checks(storage, queue)
100+
all_ready = (
101+
checks["redis"]
102+
and checks["s3"]
103+
and checks["queue_depth"] >= 0
104+
and checks["docling_serve"]
105+
)
106+
if all_ready:
107+
return {"status": "ready", "checks": checks}
108+
raise HTTPException(
109+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
110+
detail={"status": "not_ready", "checks": checks},
111+
)

tests/integration/test_health.py

Lines changed: 77 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -7,30 +7,43 @@
77
from src.dependencies import get_queue_service, get_storage_service
88
from src.main import app
99

10+
pytestmark = pytest.mark.integration
1011

11-
@pytest.mark.asyncio
12-
async def test_health_check_healthy(client):
13-
"""Test health check when all services are healthy."""
14-
# Create mock services
12+
13+
def _override(storage_ok: bool, redis_ok: bool, queue_depth: int):
14+
"""Helper: install dependency overrides with given outcomes."""
1515
mock_storage = MagicMock()
16-
mock_storage.check_s3_access = AsyncMock(return_value=True)
16+
mock_storage.check_s3_access = AsyncMock(return_value=storage_ok)
1717

1818
mock_queue = MagicMock()
19-
mock_queue.check_redis_connection = AsyncMock(return_value=True)
20-
mock_queue.check_queue_depth = AsyncMock(return_value=5)
19+
mock_queue.check_redis_connection = AsyncMock(return_value=redis_ok)
20+
mock_queue.check_queue_depth = AsyncMock(return_value=queue_depth)
2121

22-
# Override dependencies
2322
app.dependency_overrides[get_storage_service] = lambda: mock_storage
2423
app.dependency_overrides[get_queue_service] = lambda: mock_queue
2524

25+
26+
def _patched_docling(healthy: bool):
27+
"""Helper: patch the docling client to report given health."""
28+
mock_docling = MagicMock()
29+
mock_docling.check_health = AsyncMock(return_value=healthy)
30+
return patch(
31+
"src.services.docling_serve_client.get_docling_client",
32+
return_value=mock_docling,
33+
)
34+
35+
36+
# ---------- /health (tolerant) ----------
37+
38+
39+
@pytest.mark.asyncio
40+
async def test_health_check_healthy(client):
41+
"""All checks pass → 200 healthy."""
42+
_override(storage_ok=True, redis_ok=True, queue_depth=5)
2643
try:
27-
# Mock docling client as healthy
28-
mock_docling = MagicMock()
29-
mock_docling.check_health = AsyncMock(return_value=True)
30-
with patch("src.services.docling_serve_client.get_docling_client", return_value=mock_docling):
44+
with _patched_docling(True):
3145
response = client.get("/health")
3246

33-
# Assertions
3447
assert response.status_code == status.HTTP_200_OK
3548
data = response.json()
3649
assert data["status"] == "healthy"
@@ -39,31 +52,17 @@ async def test_health_check_healthy(client):
3952
assert data["checks"]["queue_depth"] == 5
4053
assert data["checks"]["docling_serve"] is True
4154
finally:
42-
# Cleanup overrides
4355
app.dependency_overrides.clear()
4456

4557

4658
@pytest.mark.asyncio
4759
async def test_health_check_degraded_when_docling_down(client):
48-
"""Test health returns 200 with degraded status when docling-serve is down."""
49-
mock_storage = MagicMock()
50-
mock_storage.check_s3_access = AsyncMock(return_value=True)
51-
52-
mock_queue = MagicMock()
53-
mock_queue.check_redis_connection = AsyncMock(return_value=True)
54-
mock_queue.check_queue_depth = AsyncMock(return_value=0)
55-
56-
app.dependency_overrides[get_storage_service] = lambda: mock_storage
57-
app.dependency_overrides[get_queue_service] = lambda: mock_queue
58-
60+
"""/health stays 200 with status=degraded when only docling-serve is down."""
61+
_override(storage_ok=True, redis_ok=True, queue_depth=0)
5962
try:
60-
# Mock docling client as unhealthy (e.g. during model loading)
61-
mock_docling = MagicMock()
62-
mock_docling.check_health = AsyncMock(return_value=False)
63-
with patch("src.services.docling_serve_client.get_docling_client", return_value=mock_docling):
63+
with _patched_docling(False):
6464
response = client.get("/health")
6565

66-
# Should return 200 (not 503) — docling_serve is non-fatal
6766
assert response.status_code == status.HTTP_200_OK
6867
data = response.json()
6968
assert data["status"] == "degraded"
@@ -75,35 +74,62 @@ async def test_health_check_degraded_when_docling_down(client):
7574

7675

7776
@pytest.mark.asyncio
78-
async def test_health_check_unhealthy(client):
79-
"""Test health check when core services are unhealthy."""
80-
# Create mock services
81-
mock_storage = MagicMock()
82-
mock_storage.check_s3_access = AsyncMock(return_value=False)
77+
async def test_health_check_unhealthy_when_core_down(client):
78+
"""/health returns 503 when a core store is unreachable."""
79+
_override(storage_ok=False, redis_ok=False, queue_depth=-1)
80+
try:
81+
response = client.get("/health")
82+
assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
83+
finally:
84+
app.dependency_overrides.clear()
8385

84-
mock_queue = MagicMock()
85-
mock_queue.check_redis_connection = AsyncMock(return_value=False)
86-
mock_queue.check_queue_depth = AsyncMock(return_value=-1)
8786

88-
# Override dependencies
89-
app.dependency_overrides[get_storage_service] = lambda: mock_storage
90-
app.dependency_overrides[get_queue_service] = lambda: mock_queue
87+
# ---------- /health/ready (strict) ----------
9188

89+
90+
@pytest.mark.asyncio
91+
async def test_readiness_ready_when_all_deps_ok(client):
92+
"""/health/ready returns 200 ready only when every dep is reachable."""
93+
_override(storage_ok=True, redis_ok=True, queue_depth=0)
9294
try:
93-
# Check health
94-
response = client.get("/health")
95+
with _patched_docling(True):
96+
response = client.get("/health/ready")
97+
98+
assert response.status_code == status.HTTP_200_OK
99+
data = response.json()
100+
assert data["status"] == "ready"
101+
assert data["checks"]["docling_serve"] is True
102+
finally:
103+
app.dependency_overrides.clear()
104+
105+
106+
@pytest.mark.asyncio
107+
async def test_readiness_not_ready_when_docling_down(client):
108+
"""/health/ready returns 503 when docling-serve is unhealthy."""
109+
_override(storage_ok=True, redis_ok=True, queue_depth=0)
110+
try:
111+
with _patched_docling(False):
112+
response = client.get("/health/ready")
95113

96-
# Assertions
97114
assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
115+
data = response.json()
116+
assert data["detail"]["status"] == "not_ready"
117+
assert data["detail"]["checks"]["docling_serve"] is False
98118
finally:
99-
# Cleanup overrides
100119
app.dependency_overrides.clear()
101120

102121

103-
def test_readiness_check(client):
104-
"""Test readiness check endpoint."""
105-
response = client.get("/health/ready")
122+
@pytest.mark.asyncio
123+
async def test_readiness_not_ready_when_redis_down(client):
124+
"""/health/ready returns 503 when Redis is unreachable, even if other deps are up."""
125+
_override(storage_ok=True, redis_ok=False, queue_depth=0)
126+
try:
127+
with _patched_docling(True):
128+
response = client.get("/health/ready")
106129

107-
assert response.status_code == status.HTTP_200_OK
108-
data = response.json()
109-
assert data["status"] == "ready"
130+
assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
131+
data = response.json()
132+
assert data["detail"]["status"] == "not_ready"
133+
assert data["detail"]["checks"]["redis"] is False
134+
finally:
135+
app.dependency_overrides.clear()

0 commit comments

Comments
 (0)