From 13edae99f46027ef958cda17b1538e55a7d52d15 Mon Sep 17 00:00:00 2001 From: fxbin Date: Sun, 27 Sep 2026 21:50:30 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(backend):=20health=20ready=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E7=9C=9F=E5=AE=9E=20OLTP=20=E6=8E=A2=E6=B5=8B?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E8=BE=BE=E8=BF=94=E5=9B=9E=20503?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/main.py | 24 ++- backend/tests/test_health_endpoints.py | 165 ++++++++++++++++++ .../tests/test_settings_notifications_api.py | 14 ++ 3 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 backend/tests/test_health_endpoints.py diff --git a/backend/app/main.py b/backend/app/main.py index ec04077d..9f2e5513 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -581,6 +581,17 @@ async def health_ready(): 用于"服务是否可以接收流量"的判断(部署/路由层)。 """ diagnostics = database_diagnostics(database_profile) + + # OLTP 连通性必须真实探测:diagnostics 是纯元数据 dict,不能作为就绪依据(#71)。 + oltp_error: str | None = None + try: + async with async_session() as db: + from sqlalchemy import text + + await db.execute(text("SELECT 1")) + except Exception as exc: + oltp_error = redact_database_secrets(type(exc).__name__, database_profile) + try: from app.services.duckdb_service import get_analytics, run_query @@ -600,17 +611,20 @@ async def health_ready(): except Exception: scheduler_running = False - # 判定:DB OK 即 ready(DuckDB 有 fallback,scheduler 可能被配置禁用) - db_ok = diagnostics.get("oltp") is not None - overall = "ready" if db_ok else "not_ready" - - return { + # 判定:OLTP 可达即 ready(DuckDB 有 fallback,scheduler 可能被配置禁用, + # 两者是受支持部署形态,见 DEPLOYMENT.md §1.4);OLTP 不可达 → not_ready。 + overall = "ready" if oltp_error is None else "not_ready" + payload = { "status": overall, "service": "topiceye-backend", "database": { "backend": database_profile.backend, **diagnostics, + **({"oltp_error": oltp_error} if oltp_error else {}), "duckdb": duckdb_status, }, "scheduler": {"running": scheduler_running}, } + if oltp_error is not None: + return JSONResponse(status_code=503, content=payload) + return payload diff --git a/backend/tests/test_health_endpoints.py b/backend/tests/test_health_endpoints.py new file mode 100644 index 00000000..931ca9ac --- /dev/null +++ b/backend/tests/test_health_endpoints.py @@ -0,0 +1,165 @@ +"""路由级健康端点语义回归(#71 / D-8)。 + +钉死 /health/live 与 /health/ready 的四态契约(DEPLOYMENT.md §3.1 / §1.4): +- 正常:ready 200; +- 调度器禁用:仍 ready(scheduler.running=false 是受支持形态); +- DuckDB 降级:仍 ready(duckdb.available=false 是受支持形态); +- OLTP 不可达:not_ready + 503。 +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import httpx +import pytest + +import app.main as app_main +from app.services import duckdb_service + +# 健康端点直连 app 级 async_session(全局引擎连接池);pytest-asyncio 默认每测试 +# 换事件循环会让池内连接跨循环复用而炸(InterfaceError),本文件共用一个 loop。 +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +def _client() -> httpx.AsyncClient: + return httpx.AsyncClient( + transport=httpx.ASGITransport(app=app_main.app), + base_url="http://testserver", + ) + + +def _duckdb_ok(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(duckdb_service, "get_analytics", lambda: SimpleNamespace(status="fake")) + + async def _run_query(_): + return {"status": "ok", "available": True} + + monkeypatch.setattr(duckdb_service, "run_query", _run_query) + + +def _duckdb_down(monkeypatch: pytest.MonkeyPatch) -> None: + def _raise(): + raise RuntimeError("duckdb attach failed") + + monkeypatch.setattr(duckdb_service, "get_analytics", _raise) + + +def _oltp_ok(monkeypatch: pytest.MonkeyPatch) -> None: + """stub OLTP 探测为成功。 + + conftest 的 autouse clean_tables 每测试前 pg_terminate_backend 会杀掉 + app 级连接池里的存量连接,同模块多个用例连续使用真实池必然随机踩到 + 被杀连接(InterfaceError → 503)。探测的异常路径由 + test_ready_oltp_unreachable_returns_503 覆盖,真实池路径由首位的 + test_live_ok 覆盖,故此处 stub。 + """ + + class _ProbeSession: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def execute(self, _stmt): + return None + + monkeypatch.setattr(app_main, "async_session", lambda: _ProbeSession()) + +def _oltp_down(monkeypatch: pytest.MonkeyPatch) -> None: + class _BrokenSessionFactory: + def __call__(self): + return self + + async def __aenter__(self): + raise ConnectionError("oltp unreachable") + + async def __aexit__(self, *args): + return False + + monkeypatch.setattr(app_main, "async_session", _BrokenSessionFactory()) + + + +async def test_live_ok_returns_alive(): + async with _client() as client: + resp = await client.get("/health/live") + assert resp.status_code == 200 + assert resp.json()["status"] == "alive" + + + +async def test_live_unhealthy_when_oltp_down(monkeypatch): + _oltp_down(monkeypatch) + async with _client() as client: + resp = await client.get("/health/live") + assert resp.status_code == 503 + assert resp.json()["status"] == "unhealthy" + + + +async def test_ready_ok(monkeypatch): + _duckdb_ok(monkeypatch) + _oltp_ok(monkeypatch) + async with _client() as client: + resp = await client.get("/health/ready") + assert resp.status_code == 200 + payload = resp.json() + assert payload["status"] == "ready" + assert payload["database"]["duckdb"]["available"] is True + assert "scheduler" in payload + + + +async def test_ready_scheduler_disabled_still_ready(monkeypatch): + """SCHEDULER_ENABLED=false 是受支持形态:调度器不在跑不影响就绪。 + + 测试进程从不 start_scheduler,scheduler.running 天然为 False—— + 该属性是只读 property,无需(也不能)monkeypatch。 + """ + _duckdb_ok(monkeypatch) + _oltp_ok(monkeypatch) + async with _client() as client: + resp = await client.get("/health/ready") + assert resp.status_code == 200 + payload = resp.json() + assert payload["status"] == "ready" + assert payload["scheduler"]["running"] is False + + + +async def test_ready_duckdb_degraded_still_ready(monkeypatch): + """DuckDB 降级(生产默认形态)不影响就绪,但必须如实上报不可用。""" + _duckdb_down(monkeypatch) + _oltp_ok(monkeypatch) + async with _client() as client: + resp = await client.get("/health/ready") + assert resp.status_code == 200 + payload = resp.json() + assert payload["status"] == "ready" + assert payload["database"]["duckdb"]["available"] is False + assert payload["database"]["duckdb"]["status"] == "error" + + + +async def test_ready_oltp_unreachable_returns_503(monkeypatch): + """核心回归(#71):OLTP 不可达必须 not_ready + 503,而非恒 ready。""" + _duckdb_ok(monkeypatch) + _oltp_down(monkeypatch) + async with _client() as client: + resp = await client.get("/health/ready") + assert resp.status_code == 503 + payload = resp.json() + assert payload["status"] == "not_ready" + assert payload["database"]["oltp_error"] == "ConnectionError" + + + +async def test_health_alias_matches_ready(monkeypatch): + _duckdb_ok(monkeypatch) + _oltp_ok(monkeypatch) + async with _client() as client: + resp = await client.get("/health") + assert resp.status_code == 200 + assert resp.json()["status"] == "ready" diff --git a/backend/tests/test_settings_notifications_api.py b/backend/tests/test_settings_notifications_api.py index 9d11eeef..941ea3e5 100644 --- a/backend/tests/test_settings_notifications_api.py +++ b/backend/tests/test_settings_notifications_api.py @@ -211,6 +211,20 @@ def fail_get_analytics(): monkeypatch.setattr(app_main, "database_profile", profile) monkeypatch.setattr(duckdb_service, "get_analytics", fail_get_analytics) + # 本测试关注 DuckDB 报错脱敏;stub 掉 OLTP 探测,避免 app 级全局连接池 + # 在 function-scoped 事件循环下跨循环复用连接(见 test_health_endpoints.py)。 + class _ProbeSession: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def execute(self, _stmt): + return None + + monkeypatch.setattr(app_main, "async_session", lambda: _ProbeSession()) + payload = await app_main.health_check() error = payload["database"]["duckdb"]["error"] From fd4777e06071c9f09ccf71770b818cf5b62920da Mon Sep 17 00:00:00 2001 From: fxbin Date: Sun, 27 Sep 2026 22:12:29 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(backend):=20=E5=81=A5=E5=BA=B7=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=E6=B5=8B=E8=AF=95=E6=A0=BC=E5=BC=8F=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/test_health_endpoints.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/backend/tests/test_health_endpoints.py b/backend/tests/test_health_endpoints.py index 931ca9ac..baf9b835 100644 --- a/backend/tests/test_health_endpoints.py +++ b/backend/tests/test_health_endpoints.py @@ -67,6 +67,7 @@ async def execute(self, _stmt): monkeypatch.setattr(app_main, "async_session", lambda: _ProbeSession()) + def _oltp_down(monkeypatch: pytest.MonkeyPatch) -> None: class _BrokenSessionFactory: def __call__(self): @@ -81,7 +82,6 @@ async def __aexit__(self, *args): monkeypatch.setattr(app_main, "async_session", _BrokenSessionFactory()) - async def test_live_ok_returns_alive(): async with _client() as client: resp = await client.get("/health/live") @@ -89,7 +89,6 @@ async def test_live_ok_returns_alive(): assert resp.json()["status"] == "alive" - async def test_live_unhealthy_when_oltp_down(monkeypatch): _oltp_down(monkeypatch) async with _client() as client: @@ -98,7 +97,6 @@ async def test_live_unhealthy_when_oltp_down(monkeypatch): assert resp.json()["status"] == "unhealthy" - async def test_ready_ok(monkeypatch): _duckdb_ok(monkeypatch) _oltp_ok(monkeypatch) @@ -111,7 +109,6 @@ async def test_ready_ok(monkeypatch): assert "scheduler" in payload - async def test_ready_scheduler_disabled_still_ready(monkeypatch): """SCHEDULER_ENABLED=false 是受支持形态:调度器不在跑不影响就绪。 @@ -128,7 +125,6 @@ async def test_ready_scheduler_disabled_still_ready(monkeypatch): assert payload["scheduler"]["running"] is False - async def test_ready_duckdb_degraded_still_ready(monkeypatch): """DuckDB 降级(生产默认形态)不影响就绪,但必须如实上报不可用。""" _duckdb_down(monkeypatch) @@ -142,7 +138,6 @@ async def test_ready_duckdb_degraded_still_ready(monkeypatch): assert payload["database"]["duckdb"]["status"] == "error" - async def test_ready_oltp_unreachable_returns_503(monkeypatch): """核心回归(#71):OLTP 不可达必须 not_ready + 503,而非恒 ready。""" _duckdb_ok(monkeypatch) @@ -155,7 +150,6 @@ async def test_ready_oltp_unreachable_returns_503(monkeypatch): assert payload["database"]["oltp_error"] == "ConnectionError" - async def test_health_alias_matches_ready(monkeypatch): _duckdb_ok(monkeypatch) _oltp_ok(monkeypatch)