From 9fafaef4949cd366a7fc95d93799879c0d7bcad5 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Thu, 2 Jul 2026 17:39:39 +0200 Subject: [PATCH] feat(backend): add unauthenticated /health liveness endpoint Add GET /health mounted outside the /api prefix so it bypasses per-route auth and is reachable as a plain liveness/readiness probe. Returns {status, version, db_ok}, degrading to db_ok=false with a 200 (not a raised exception) on database failure so the probe stays useful even when the DB is unreachable. Add a Docker HEALTHCHECK that curls it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z --- Dockerfile | 5 +++ backend/src/main.py | 5 +++ backend/src/module/api/health.py | 29 ++++++++++++++ backend/src/test/test_api_health.py | 60 +++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 backend/src/module/api/health.py create mode 100644 backend/src/test/test_api_health.py diff --git a/Dockerfile b/Dockerfile index 9d73b6b1..6d3af984 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,4 +46,9 @@ ENV PATH="/app/.venv/bin:$PATH" EXPOSE 7892 VOLUME ["/app/config", "/app/data"] +# Liveness probe against the unauthenticated /health route; give the app +# room to run migrations on first boot before failures count. +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD wget -qO- http://localhost:7892/health || exit 1 + ENTRYPOINT ["tini", "-g", "--", "/entrypoint.sh"] diff --git a/backend/src/main.py b/backend/src/main.py index 44ab3ce5..48815c78 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -11,6 +11,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from module.api import v1 +from module.api.health import router as health_router from module.conf import VERSION, settings, setup_logger from module.core import AppContext from module.mcp import create_mcp_app @@ -65,6 +66,10 @@ def create_app() -> FastAPI: # mount routers app.include_router(v1, prefix="/api") + # unauthenticated liveness probe, mounted at the app root (not /api) so + # it stays reachable without auth for container/orchestrator health checks + app.include_router(health_router) + # mount MCP server (SSE transport for LLM tool integration) app.mount("/mcp", create_mcp_app(ctx)) diff --git a/backend/src/module/api/health.py b/backend/src/module/api/health.py new file mode 100644 index 00000000..d080d314 --- /dev/null +++ b/backend/src/module/api/health.py @@ -0,0 +1,29 @@ +"""无鉴权的存活探针(liveness probe),供容器/编排系统健康检查使用。 + +与 program.py 中的 /status 不同,本路由不挂在 /api/v1 前缀下、也不依赖 +get_current_user,因此可以直接被 Docker HEALTHCHECK 等外部探针访问。 +""" + +import logging + +from fastapi import APIRouter +from sqlalchemy import text + +from module.conf import VERSION +from module.database import Database + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["health"]) + + +@router.get("/health") +async def health() -> dict: + """存活探针:始终返回 200,db_ok 反映一次简单查询是否成功。""" + try: + async with Database() as db: + await db.session.execute(text("SELECT 1")) + db_ok = True + except Exception as e: + logger.warning(f"[Health] DB check failed: {e}") + db_ok = False + return {"status": "ok", "version": VERSION, "db_ok": db_ok} diff --git a/backend/src/test/test_api_health.py b/backend/src/test/test_api_health.py new file mode 100644 index 00000000..c1b81482 --- /dev/null +++ b/backend/src/test/test_api_health.py @@ -0,0 +1,60 @@ +"""Tests for the unauthenticated /health liveness probe.""" + +from unittest.mock import patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from module.api.health import router as health_router + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def app(): + """App with only the health router mounted, at the root (no /api prefix).""" + app = FastAPI() + app.include_router(health_router) + return app + + +@pytest.fixture +def client(app): + """Plain TestClient — no auth override, matching the probe's real usage.""" + return TestClient(app) + + +# --------------------------------------------------------------------------- +# GET /health +# --------------------------------------------------------------------------- + + +class TestHealth: + def test_health_no_auth_required_returns_200(self, client): + """GET /health succeeds without any auth dependency/header.""" + response = client.get("/health") + assert response.status_code == 200 + + def test_health_reports_version_and_db_ok(self, client): + """GET /health returns status/version/db_ok, DB reachable -> db_ok True.""" + with patch("module.api.health.VERSION", "3.3.0-test"): + response = client.get("/health") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert data["version"] == "3.3.0-test" + assert data["db_ok"] is True + + def test_health_db_failure_reports_db_ok_false(self, client): + """A DB query failure is caught: still 200, but db_ok is False.""" + with patch("module.api.health.Database", side_effect=RuntimeError("db down")): + response = client.get("/health") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert data["db_ok"] is False