deer-flow/backend/tests/test_gateway_health.py
Michael 4791e94a73
feat(gateway): add /health/ready readiness probe backed by the database (#5166)
* feat(gateway): add /health/ready readiness probe backed by the database

## Why

GET /health only proves the process is up: it returns 200 even when the persistence engine cannot reach the database. Orchestrators already treat it as a readiness gate (docker-compose.yaml marks the gateway service healthy and nginx depends_on service_healthy), so a DB outage or a still-migrating Postgres leaves the stack 'healthy' while every request fails.

## What changed

- New GET /health/ready endpoint: bounded SELECT 1 against the existing persistence engine (deerflow.persistence.engine.get_engine) with a 2s timeout.

- Response is 200 {'status': 'ready', 'database': 'ok'} when reachable, 503 {'status': 'degraded', 'database': 'unreachable'} when the probe fails, and 200 ready with database=not_configured for backend=memory (nothing to probe).

- GET /health is unchanged (pure liveness), and /health/ready is public through the existing /health auth whitelist.

- docker-compose.yaml gateway healthcheck now polls /health/ready so service_healthy reflects database reachability.

- Documented both endpoints in backend/app/gateway/AGENTS.md.

## Surface area

- [x] Backend API - new GET /health/ready endpoint under backend/app/gateway

- [x] Sandbox / Docker - gateway healthcheck in docker/docker-compose.yaml now gates on readiness

- [ ] Frontend UI / Agents / Skills / Dependencies

- [x] Default behavior change - existing /health unchanged; the prod compose healthcheck is stricter (503 while the database is unreachable)

## Validation

- New unit tests in backend/tests/test_gateway_health.py cover ok / unreachable / not_configured probe results and the 200/503 payload mapping (6 passed).

- app.gateway.app imports cleanly and registers both /health and /health/ready.

- ruff check + ruff format clean.

## AI assistance

**Tool(s) used:** Codex (coding agent)

**How you used it:** design, implementation, and unit tests produced with AI assistance; reviewed before commit.

- [ ] I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.

* fix(helm): point the gateway readiness probe at /health/ready

## Why

Review on #5166 (willem-bd, P1): the chart still probed /health for readiness,
so Kubernetes marked the pod ready and routed traffic while the database was
unreachable - exactly the failure mode /health/ready was added to catch.

## What changed

- deploy/helm/deer-flow/templates/gateway-deployment.yaml: readinessProbe
  httpGet.path now hits /health/ready (DB-backed, 503 while the database is
  unreachable). The liveness probe stays on /health.

## Verification

- One-line path change inside the existing readinessProbe block; git diff
  confirms only the readiness path changed (liveness untouched).

## AI assistance

**Tool(s) used:** Codex (coding agent)

**How you used it:** implemented the reviewer-requested probe path change; reviewed before commit.

- [ ] I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.

* fix(gateway): readiness probe also checks the effective checkpointer/Store backend

## Why

Follow-up review on #5166 (willem-bd, P1): get_engine() only represents the
ORM backend selected by `database:`. The legacy `checkpointer:` section takes
precedence for the LangGraph checkpointer and Store, so a split configuration
(a local SQLite/memory `database:` with `checkpointer.type: postgres`) could
report 200 while the PostgreSQL backend agent runs depend on was down.

## What changed

- GET /health/ready now probes both persistence halves: the ORM engine behind
  `database:` (unchanged) and the effective LangGraph checkpointer/Store
  backend resolved with the runtime's own rule (legacy `checkpointer:` config,
  otherwise derived from `database:`), for memory/sqlite/postgres backends.

- The payload gains a `checkpointer` field with the same
  ok / not_configured / unreachable vocabulary as `database`; 503 degraded is
  returned when either probe is unreachable.

- Probes are bounded by the existing 2s timeout: sqlite via aiosqlite SELECT 1
  on the resolved path, postgres via a bounded psycopg AsyncConnection SELECT 1
  on the DSN with the configured search_path. A missing driver for a configured
  backend degrades readiness (the runtime could not run either).

- Documented the two-probe semantics in the endpoint docstring and
  backend/app/gateway/AGENTS.md.

## Verification

- New tests: healthy ORM engine + unreachable legacy checkpointer backend ->
  503 degraded with database: ok / checkpointer: unreachable; checkpointer
  probe mapping for memory/sqlite(postgres missing-driver) backends; existing
  payload tests now pin the checkpointer field.

- cd backend && python -m pytest tests/test_gateway_health.py: 11 passed.

- app.gateway.app imports cleanly; ruff check + ruff format clean.

## AI assistance

**Tool(s) used:** Codex (coding agent)

**How you used it:** design, implementation, and unit tests produced with AI assistance; reviewed before commit.

- [ ] I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.

* fix(gateway): bound /health/ready to one deadline and probe the startup checkpointer snapshot

## Why

Second round of review on #5166 (zhfeng P1/P2, willem-bd P1/P1/P2). Three
correctness issues remained in the readiness endpoint:

- The database and checkpointer probes ran sequentially, each allowed 2s, so a
  healthy response could take almost 4s - past Kubernetes' 1s default
  readinessProbe timeout and inside Docker's 3s client timeout. A slow but
  healthy backend could make every replica unready.

- The checkpointer probe re-resolved process-wide, hot-reloaded configuration
  per request, while app.state.checkpointer/store are built once from the
  startup_config snapshot in langgraph_runtime(). After a live config edit the
  endpoint could probe a backend the running gateway does not use, and a
  resolution failure was swallowed into None -> not_configured -> 200.

- The SQLite probe opened the path with aiosqlite.connect(), which creates the
  file when missing: a deleted checkpoint database was silently resurrected as
  an empty file and reported ok instead of surfacing the outage.

## What changed

- backend/app/gateway/health.py: the two probes now run concurrently beneath a
  single endpoint-wide deadline (_READINESS_DEADLINE_SECONDS=3.0) so a healthy
  response completes within one probe window (~2s), never the sum of both.
  A probe that overruns the deadline degrades the endpoint instead of hanging.

- langgraph_runtime() now records the checkpointer/Store config resolved from
  the same startup_config snapshot its checkpointer/store singletons are built
  from (app.state.checkpointer_config); /health/ready probes that snapshot and
  never re-resolves hot-reloaded config. resolve_checkpointer_config() returns
  None on resolution failure and the endpoint fails closed (503, checkpointer:
  unreachable) instead of reporting not_configured.

- The SQLite probe opens disk-backed paths with the non-creating mode=rw URI
  flag, so a missing database file stays missing and yields unreachable;
  in-memory forms (:memory:, file:...mode=memory) have nothing external to
  probe and report not_configured like the memory backend.

- Orchestrator timeouts now sit above the endpoint bound: Helm readinessProbe
  gains timeoutSeconds: 5 (Kubernetes default is 1s) and the docker-compose
  gateway healthcheck client timeout moves from 3s to 5s.

## Verification

- New regression tests: concurrent probes keep total elapsed time within one
  probe window; a probe ignoring its budget trips the endpoint deadline to 503;
  missing SQLite file stays absent and yields unreachable; in-memory SQLite
  forms map to not_configured; missing startup snapshot / config resolution
  failure fail closed to 503; resolve_checkpointer_config() raising is covered.

- cd backend && python -m pytest tests/test_gateway_health.py: 21 passed;
  tests/test_gateway_docs_toggle.py and lifespan/shutdown gateway suites pass.

- ruff check + ruff format clean on all changed files.

## AI assistance

**Tool(s) used:** Codex (coding agent)

**How you used it:** implemented the reviewer-requested concurrency/deadline, startup-snapshot probing, fail-closed resolution, and non-creating SQLite probe; reviewed before commit.

- [ ] I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.

* fix(gateway): serialize connection-opening readiness probes behind a strict gate

## Why

Review on #5166 (willem-bd, P1): every request to /health/ready opened a new
PostgreSQL connection in _probe_postgres_backend, outside both the ORM pool and
the runtime checkpointer pool. The route is public through the /health auth
prefix and nginx proxies /health/*, so concurrent unauthenticated requests
could create an unbounded number of connections (each held for up to two
seconds), exhaust PostgreSQL max_connections, and take down both normal
traffic and the readiness probe itself.

## What changed

- backend/app/gateway/health.py: connection-opening checkpointer probes
  (sqlite connect, postgres AsyncConnection.connect) now run inside a strict
  per-process gate - an asyncio.Lock cached per running event loop - so at
  most one probe connection can be in flight per worker process. Requests
  that queue behind the gate are still shed by the existing endpoint-wide
  deadline, so a flood cannot pile up new connections or open files.

- Memory and unknown-backend decisions stay outside the gate; payload and
  probe semantics are unchanged. The serialization is documented in the
  module docstring and backend/app/gateway/AGENTS.md.

## Verification

- New regression test: 8 concurrent readiness_payload() requests against an
  instrumented sqlite probe assert the maximum number of in-flight probe
  connections is 1 while every request still returns 200.

- cd backend && python -m pytest tests/test_gateway_health.py: 22 passed;
  tests/test_gateway_docs_toggle.py and tests/test_gateway_lifespan_shutdown.py
  also pass on the merged main head.

- ruff check + ruff format clean on all changed files.

## AI assistance

**Tool(s) used:** Codex (coding agent)

**How you used it:** implemented the reviewer-requested strict concurrency bound for the public readiness probe; reviewed before commit.

- [ ] I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.
2026-09-04 23:26:53 +08:00

304 lines
10 KiB
Python

"""Unit tests for the gateway readiness probe (app.gateway.health)."""
import asyncio
import pathlib
import sqlite3
import sys
import time
from contextlib import asynccontextmanager
import pytest
import app.gateway.health as health_module
from app.gateway.health import (
DATABASE_NOT_CONFIGURED,
DATABASE_OK,
DATABASE_UNREACHABLE,
_probe_checkpointer_backend,
check_database_health,
readiness_payload,
resolve_checkpointer_config,
)
from deerflow.config.checkpointer_config import CheckpointerConfig
class _FakeConnection:
async def execute(self, *args, **kwargs):
return None
class _FakeEngine:
def __init__(self, *, unreachable: bool = False):
self._unreachable = unreachable
def connect(self):
@asynccontextmanager
async def _connect():
if self._unreachable:
raise RuntimeError("database is down")
yield _FakeConnection()
return _connect()
def _create_sqlite_file(path: pathlib.Path) -> None:
"""Create a valid (empty) SQLite database file at *path*."""
path.parent.mkdir(parents=True, exist_ok=True)
sqlite3.connect(str(path)).close()
@pytest.mark.anyio
async def test_check_database_health_without_engine(monkeypatch):
"""backend=memory (no engine) must report not_configured, never unreachable."""
monkeypatch.setattr("app.gateway.health.get_engine", lambda: None)
assert await check_database_health() == DATABASE_NOT_CONFIGURED
@pytest.mark.anyio
async def test_check_database_health_reachable(monkeypatch):
monkeypatch.setattr("app.gateway.health.get_engine", lambda: _FakeEngine())
assert await check_database_health() == DATABASE_OK
@pytest.mark.anyio
async def test_check_database_health_unreachable(monkeypatch):
monkeypatch.setattr("app.gateway.health.get_engine", lambda: _FakeEngine(unreachable=True))
assert await check_database_health() == DATABASE_UNREACHABLE
@pytest.mark.anyio
async def test_readiness_payload_ready_when_database_ok_and_memory_backend(monkeypatch):
monkeypatch.setattr("app.gateway.health.get_engine", lambda: _FakeEngine())
status_code, payload = await readiness_payload(CheckpointerConfig(type="memory"))
assert status_code == 200
assert payload["status"] == "ready"
assert payload["database"] == DATABASE_OK
assert payload["checkpointer"] == DATABASE_NOT_CONFIGURED
@pytest.mark.anyio
async def test_readiness_payload_degraded_when_database_unreachable(monkeypatch):
monkeypatch.setattr("app.gateway.health.get_engine", lambda: _FakeEngine(unreachable=True))
status_code, payload = await readiness_payload(CheckpointerConfig(type="memory"))
assert status_code == 503
assert payload["status"] == "degraded"
assert payload["database"] == DATABASE_UNREACHABLE
assert payload["checkpointer"] == DATABASE_NOT_CONFIGURED
@pytest.mark.anyio
async def test_readiness_payload_ready_when_nothing_configured(monkeypatch):
"""backend=memory end to end must stay ready with not_configured results."""
monkeypatch.setattr("app.gateway.health.get_engine", lambda: None)
status_code, payload = await readiness_payload(CheckpointerConfig(type="memory"))
assert status_code == 200
assert payload["status"] == "ready"
assert payload["database"] == DATABASE_NOT_CONFIGURED
assert payload["checkpointer"] == DATABASE_NOT_CONFIGURED
@pytest.mark.anyio
async def test_readiness_payload_degraded_when_checkpointer_unreachable_but_database_ok(tmp_path, monkeypatch):
"""A healthy ORM engine must not mask an unreachable legacy checkpointer backend."""
monkeypatch.setattr("app.gateway.health.get_engine", lambda: _FakeEngine())
config = CheckpointerConfig(type="sqlite", connection_string=str(tmp_path / "missing" / "checkpoints.db"))
status_code, payload = await readiness_payload(config)
assert status_code == 503
assert payload["status"] == "degraded"
assert payload["database"] == DATABASE_OK
assert payload["checkpointer"] == DATABASE_UNREACHABLE
@pytest.mark.anyio
async def test_readiness_payload_fails_closed_without_startup_snapshot(monkeypatch):
"""No startup config snapshot must degrade readiness, never report ready."""
monkeypatch.setattr("app.gateway.health.get_engine", lambda: _FakeEngine())
status_code, payload = await readiness_payload(None)
assert status_code == 503
assert payload["status"] == "degraded"
assert payload["database"] == DATABASE_OK
assert payload["checkpointer"] == DATABASE_UNREACHABLE
@pytest.mark.anyio
async def test_readiness_probes_run_concurrently(monkeypatch):
"""Slow-but-healthy probes must not add their budgets together."""
async def _slow_ok(*args) -> str:
await asyncio.sleep(0.35)
return DATABASE_OK
monkeypatch.setattr(health_module, "check_database_health", _slow_ok)
monkeypatch.setattr(health_module, "_probe_checkpointer_backend", _slow_ok)
started = time.perf_counter()
status_code, payload = await readiness_payload(CheckpointerConfig(type="memory"))
elapsed = time.perf_counter() - started
assert status_code == 200
assert payload["database"] == DATABASE_OK
assert payload["checkpointer"] == DATABASE_OK
# Two sequential 0.35s probes would take ~0.7s; concurrent ones finish
# within a single probe window.
assert elapsed < 0.6
@pytest.mark.anyio
async def test_readiness_payload_enforces_endpoint_deadline(monkeypatch):
"""A probe ignoring its own budget must trip the endpoint-wide deadline."""
async def _hanging(*args) -> str:
await asyncio.sleep(30)
return DATABASE_OK
monkeypatch.setattr(health_module, "check_database_health", _hanging)
monkeypatch.setattr(health_module, "_probe_checkpointer_backend", _hanging)
monkeypatch.setattr(health_module, "_READINESS_DEADLINE_SECONDS", 0.05)
status_code, payload = await readiness_payload(CheckpointerConfig(type="memory"))
assert status_code == 503
assert payload["status"] == "degraded"
assert payload["database"] == DATABASE_UNREACHABLE
assert payload["checkpointer"] == DATABASE_UNREACHABLE
@pytest.mark.anyio
async def test_concurrent_readiness_requests_do_not_open_concurrent_probe_connections(monkeypatch):
"""Public /health/ready must serialize connection-opening probes.
An unauthenticated thundering herd must never translate into an unbounded
number of new database connections (e.g. past PostgreSQL
max_connections): at most one probe connection may be in flight at a time
per process.
"""
active = 0
max_active = 0
async def _tracked_probe(conn_string: str | None) -> str:
nonlocal active, max_active
active += 1
max_active = max(max_active, active)
try:
await asyncio.sleep(0.05)
return DATABASE_OK
finally:
active -= 1
monkeypatch.setattr(health_module, "_probe_sqlite_backend", _tracked_probe)
monkeypatch.setattr("app.gateway.health.get_engine", lambda: None)
config = CheckpointerConfig(type="sqlite", connection_string=":memory:")
results = await asyncio.gather(*(readiness_payload(config) for _ in range(8)))
assert [status_code for status_code, _ in results] == [200] * 8
assert max_active == 1
@pytest.mark.anyio
async def test_probe_checkpointer_memory_reports_not_configured():
assert await _probe_checkpointer_backend(CheckpointerConfig(type="memory")) == DATABASE_NOT_CONFIGURED
@pytest.mark.anyio
async def test_probe_checkpointer_sqlite_reachable(tmp_path):
db_path = tmp_path / "checkpoints.db"
_create_sqlite_file(db_path)
result = await _probe_checkpointer_backend(CheckpointerConfig(type="sqlite", connection_string=str(db_path)))
assert result == DATABASE_OK
@pytest.mark.anyio
async def test_probe_checkpointer_sqlite_file_uri_reachable(tmp_path):
db_path = tmp_path / "checkpoints.db"
_create_sqlite_file(db_path)
result = await _probe_checkpointer_backend(CheckpointerConfig(type="sqlite", connection_string=pathlib.Path(db_path).as_uri()))
assert result == DATABASE_OK
@pytest.mark.anyio
async def test_probe_checkpointer_sqlite_missing_file_stays_missing_and_unreachable(tmp_path):
"""The probe must never create a missing SQLite file (regression)."""
missing = tmp_path / "checkpoints.db"
result = await _probe_checkpointer_backend(CheckpointerConfig(type="sqlite", connection_string=str(missing)))
assert result == DATABASE_UNREACHABLE
assert not missing.exists()
@pytest.mark.anyio
async def test_probe_checkpointer_sqlite_unreachable_when_parent_missing(tmp_path):
missing_parent = tmp_path / "does-not-exist" / "checkpoints.db"
result = await _probe_checkpointer_backend(CheckpointerConfig(type="sqlite", connection_string=str(missing_parent)))
assert result == DATABASE_UNREACHABLE
@pytest.mark.anyio
@pytest.mark.parametrize(
"conn_string",
[":memory:", "file:memdb1?mode=memory&cache=shared", "file::memory:?cache=shared"],
)
async def test_probe_checkpointer_sqlite_in_memory_is_not_configured(conn_string):
"""In-memory SQLite has no external state, mirroring the memory backend."""
result = await _probe_checkpointer_backend(CheckpointerConfig(type="sqlite", connection_string=conn_string))
assert result == DATABASE_NOT_CONFIGURED
@pytest.mark.anyio
async def test_probe_checkpointer_postgres_without_psycopg_is_unreachable(monkeypatch):
monkeypatch.setitem(sys.modules, "psycopg", None)
result = await _probe_checkpointer_backend(
CheckpointerConfig(
type="postgres",
connection_string="postgresql://user:pass@localhost:5432/deerflow",
)
)
assert result == DATABASE_UNREACHABLE
def test_resolve_checkpointer_config_passes_through_resolution(monkeypatch):
resolved = CheckpointerConfig(type="memory")
monkeypatch.setattr(
"deerflow.runtime.checkpointer.provider._resolve_checkpointer_config",
lambda app_config: resolved,
)
assert resolve_checkpointer_config(object()) is resolved
def test_resolve_checkpointer_config_failure_fails_closed(monkeypatch):
"""A resolution failure must surface as None, never as a memory default."""
def _raise(app_config):
raise RuntimeError("broken checkpointer config")
monkeypatch.setattr(
"deerflow.runtime.checkpointer.provider._resolve_checkpointer_config",
_raise,
)
assert resolve_checkpointer_config(object()) is None