104 lines
3.8 KiB
Python
104 lines
3.8 KiB
Python
"""Dependency-aware health integration coverage."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import time
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from ragcore.infrastructure.health import ReadinessChecker
|
|
from ragcore.main import app, create_app
|
|
|
|
|
|
async def _ok() -> None:
|
|
return None
|
|
|
|
|
|
async def _fail() -> None:
|
|
raise OSError("database password should never escape readiness details")
|
|
|
|
|
|
async def _slow() -> None:
|
|
await asyncio.sleep(0.2)
|
|
|
|
|
|
def _client(checker: ReadinessChecker) -> TestClient:
|
|
return TestClient(create_app(readiness_checker=checker), raise_server_exceptions=False)
|
|
|
|
|
|
def test_liveness_does_not_call_downstreams() -> None:
|
|
async def must_not_run() -> None:
|
|
raise AssertionError("liveness must not probe dependencies")
|
|
|
|
with _client(
|
|
ReadinessChecker(
|
|
{"postgres": must_not_run, "qdrant": must_not_run, "storage": must_not_run}
|
|
)
|
|
) as client:
|
|
response = client.get("/health/live")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"status": "ok", "service": "ragcore"}
|
|
|
|
|
|
def test_readiness_returns_all_ok_components() -> None:
|
|
checker = ReadinessChecker({"postgres": _ok, "qdrant": _ok, "storage": _ok})
|
|
|
|
with _client(checker) as client:
|
|
response = client.get("/health/ready")
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["status"] == "ok"
|
|
assert set(body["components"]) == {"postgres", "qdrant", "storage"}
|
|
assert all(component["status"] == "ok" for component in body["components"].values())
|
|
assert all(component["code"] is None for component in body["components"].values())
|
|
|
|
|
|
def test_readiness_returns_stable_problem_details_for_dependency_failure() -> None:
|
|
checker = ReadinessChecker({"postgres": _fail, "qdrant": _ok, "storage": _ok})
|
|
|
|
with _client(checker) as client:
|
|
response = client.get("/health/ready", headers={"X-Request-ID": "health-failure-123"})
|
|
|
|
assert response.status_code == 503
|
|
assert response.headers["content-type"].startswith("application/problem+json")
|
|
body = response.json()
|
|
assert body["status"] == 503
|
|
assert body["code"] == "DEPENDENCY_UNAVAILABLE"
|
|
assert body["trace_id"] == "health-failure-123"
|
|
assert body["errors"] == [{"component": "postgres", "code": "POSTGRES_UNAVAILABLE"}]
|
|
assert "database password" not in response.text
|
|
|
|
|
|
def test_readiness_applies_strict_probe_timeout() -> None:
|
|
checker = ReadinessChecker(
|
|
{"postgres": _slow, "qdrant": _ok, "storage": _ok}, timeout_seconds=0.02
|
|
)
|
|
|
|
started = time.perf_counter()
|
|
with _client(checker) as client:
|
|
response = client.get("/health/ready")
|
|
elapsed = time.perf_counter() - started
|
|
|
|
assert response.status_code == 503
|
|
# The timeout actually firing (not the probe merely finishing fast on
|
|
# its own) is what the error code below proves -- it can only appear if
|
|
# the checker's own 20ms deadline cut the 200ms `_slow()` probe off
|
|
# early. `elapsed` therefore only needs to rule out an indefinite hang,
|
|
# not stand in for a precise timing proof: a tight millisecond-scale
|
|
# margin here (this used to assert `elapsed < 0.15`) flakes under real
|
|
# system load from TestClient/ASGI scheduling jitter alone, with the
|
|
# timeout logic itself never having failed at all.
|
|
assert response.json()["errors"] == [{"component": "postgres", "code": "POSTGRES_TIMEOUT"}]
|
|
assert elapsed < 2.0
|
|
def test_production_readiness_checks_compose_dependencies() -> None:
|
|
with TestClient(app, raise_server_exceptions=False) as client:
|
|
response = client.get("/health/ready")
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["status"] == "ok"
|
|
assert set(body["components"]) == {"postgres", "qdrant", "storage"}
|
|
assert all(component["status"] == "ok" for component in body["components"].values()) |