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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
This commit is contained in:
Estrella Pan
2026-07-02 17:39:39 +02:00
parent d9f41f4b11
commit 9fafaef494
4 changed files with 99 additions and 0 deletions

View File

@@ -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"]

View File

@@ -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))

View File

@@ -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}

View File

@@ -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