added health endpoints

This commit is contained in:
2026-06-15 21:21:01 -04:00
parent b126987b63
commit 2e68c83021
5 changed files with 245 additions and 1 deletions
+2 -1
View File
@@ -14,7 +14,7 @@ from sqlalchemy.orm import Session
from python.common import configure_logger
from python.ebook_search.api.bm25_tasks import cancel_bm25_refresh
from python.ebook_search.api.routes import admin_router, page_router, search_router
from python.ebook_search.api.routes import admin_router, health_router, page_router, search_router
from python.ebook_search.api.web import STATIC_DIR
from python.ebook_search.bm25_corpus import ensure_bm25_corpus
from python.ebook_search.config import load_config
@@ -59,6 +59,7 @@ def create_app() -> FastAPI:
)
app.include_router(admin_router)
app.include_router(health_router)
app.include_router(page_router)
app.include_router(search_router)
@@ -1,11 +1,13 @@
"""EPUB search web route modules."""
from python.ebook_search.api.routes.admin import router as admin_router
from python.ebook_search.api.routes.health import router as health_router
from python.ebook_search.api.routes.page import router as page_router
from python.ebook_search.api.routes.search import router as search_router
__all__ = [
"admin_router",
"health_router",
"page_router",
"search_router",
]
+95
View File
@@ -0,0 +1,95 @@
"""Liveness and readiness routes for the EPUB search service."""
from __future__ import annotations
import logging
from http import HTTPStatus
from typing import TYPE_CHECKING
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from sqlalchemy import literal, select
from sqlalchemy.exc import SQLAlchemyError
from python.ebook_search.api.dependencies import AppConfig
from python.ebook_search.bm25_corpus import bm25_index_exists, bm25_index_path, read_bm25_manifest
from python.ebook_search.llm_interface import check_chat_endpoint, check_embedding_endpoint
from python.fastapi_tools import DbSession
if TYPE_CHECKING:
from sqlalchemy.orm import Session
from python.ebook_search.config import EbookSearchConfig
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/health")
def health() -> dict[str, str]:
"""Liveness probe that returns ok without touching dependencies."""
return {"status": "ok"}
@router.get("/ready")
def ready(config: AppConfig, session: DbSession) -> JSONResponse:
"""Readiness probe reporting database, embedding endpoint, and BM25 index status."""
database_ok = check_database(session)
embedding_ok = check_embedding_endpoint(config)
chat_status = chat_endpoint_status(config)
bm25_status = check_bm25_status(config)
checks = {
"database": "ok" if database_ok else "fail",
"embedding": "ok" if embedding_ok else "fail",
"chat": chat_status,
"bm25": bm25_status,
}
if not database_ok:
status = "unavailable"
status_code = HTTPStatus.SERVICE_UNAVAILABLE
elif not embedding_ok or chat_status == "fail" or bm25_status == "missing":
status = "degraded"
status_code = HTTPStatus.OK
else:
status = "ready"
status_code = HTTPStatus.OK
logger.info(
"ebook_ready_check status=%s database=%s embedding=%s chat=%s bm25=%s",
status,
database_ok,
embedding_ok,
chat_status,
bm25_status,
)
return JSONResponse(content={"status": status, "checks": checks}, status_code=status_code)
def chat_endpoint_status(config: EbookSearchConfig) -> str:
"""Return the answering chat endpoint status, or disabled when answers are off."""
if not config.answer_enabled:
return "disabled"
return "ok" if check_chat_endpoint(config) else "fail"
def check_database(session: Session) -> bool:
"""Return whether the database answers a trivial query."""
try:
session.execute(select(literal(1)))
except SQLAlchemyError as error:
logger.warning("ebook_ready_database_unavailable error=%s", error)
return False
return True
def check_bm25_status(config: EbookSearchConfig) -> str:
"""Return the persisted BM25 index status without loading it into memory."""
index_path = bm25_index_path(config)
manifest = read_bm25_manifest(index_path)
if manifest is None or not bm25_index_exists(index_path, manifest):
return "missing"
if manifest.chunk_count == 0:
return "empty"
return "ok"
+30
View File
@@ -44,6 +44,36 @@ def request_embeddings(texts: Sequence[str], config: EbookSearchConfig) -> list[
raise RuntimeError(msg) from error
def check_embedding_endpoint(config: EbookSearchConfig, *, timeout_seconds: float = 5.0) -> bool:
"""Return whether the configured embedding endpoint answers a model listing."""
try:
response = httpx.get(
f"{config.embedding_base_url.rstrip('/')}/models",
headers=auth_headers(config.embedding_api_key),
timeout=timeout_seconds,
)
response.raise_for_status()
except httpx.HTTPError as error:
logger.warning("ebook_embedding_endpoint_unreachable base_url=%s error=%s", config.embedding_base_url, error)
return False
return True
def check_chat_endpoint(config: EbookSearchConfig, *, timeout_seconds: float = 5.0) -> bool:
"""Return whether the configured chat (answering) endpoint answers a model listing."""
try:
response = httpx.get(
f"{config.vllm_base_url.rstrip('/')}/models",
headers=auth_headers(config.vllm_api_key),
timeout=timeout_seconds,
)
response.raise_for_status()
except httpx.HTTPError as error:
logger.warning("ebook_chat_endpoint_unreachable base_url=%s error=%s", config.vllm_base_url, error)
return False
return True
def embedding_vectors_from_response(body: object) -> list[list[float]]:
"""Extract embedding vectors from an OpenAI-compatible embedding response."""
if not isinstance(body, dict):
+116
View File
@@ -0,0 +1,116 @@
"""Tests for EPUB search health and readiness routes."""
from __future__ import annotations
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from python.ebook_search.api.main import create_app
from python.ebook_search.config import EbookSearchConfig, RerankConfig
HEALTH_MODULE = "python.ebook_search.api.routes.health"
def fake_get_postgres_engine(**_kwargs):
"""Return an in-memory engine for route tests."""
return create_engine("sqlite+pysqlite:///:memory:", future=True)
def patch_app_runtime(monkeypatch):
monkeypatch.setattr("python.ebook_search.api.main.get_postgres_engine", fake_get_postgres_engine)
monkeypatch.setattr("python.ebook_search.api.main.ensure_bm25_corpus", lambda _session, _config: None)
def patch_dependencies(monkeypatch, *, database=True, embedding=True, chat=True, bm25="ok"):
monkeypatch.setattr(f"{HEALTH_MODULE}.check_database", lambda _session: database)
monkeypatch.setattr(f"{HEALTH_MODULE}.check_embedding_endpoint", lambda _config: embedding)
monkeypatch.setattr(f"{HEALTH_MODULE}.check_chat_endpoint", lambda _config: chat)
monkeypatch.setattr(f"{HEALTH_MODULE}.check_bm25_status", lambda _config: bm25)
def build_client(monkeypatch, config=None):
patch_app_runtime(monkeypatch)
app = create_app()
app.state.config = config or EbookSearchConfig(rerank=RerankConfig(enabled=False))
return TestClient(app)
def test_health_returns_ok(monkeypatch) -> None:
with build_client(monkeypatch) as client:
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_ready_all_dependencies_ok(monkeypatch) -> None:
patch_dependencies(monkeypatch)
with build_client(monkeypatch) as client:
response = client.get("/ready")
assert response.status_code == 200
body = response.json()
assert body["status"] == "ready"
assert body["checks"] == {"database": "ok", "embedding": "ok", "chat": "ok", "bm25": "ok"}
def test_ready_embedding_down_is_degraded(monkeypatch) -> None:
patch_dependencies(monkeypatch, embedding=False)
with build_client(monkeypatch) as client:
response = client.get("/ready")
assert response.status_code == 200
body = response.json()
assert body["status"] == "degraded"
assert body["checks"]["embedding"] == "fail"
def test_ready_chat_down_is_degraded(monkeypatch) -> None:
patch_dependencies(monkeypatch, chat=False)
with build_client(monkeypatch) as client:
response = client.get("/ready")
assert response.status_code == 200
body = response.json()
assert body["status"] == "degraded"
assert body["checks"]["chat"] == "fail"
def test_ready_chat_disabled_when_answers_off(monkeypatch) -> None:
patch_dependencies(monkeypatch)
config = EbookSearchConfig(rerank=RerankConfig(enabled=False), answer_enabled=False)
with build_client(monkeypatch, config) as client:
response = client.get("/ready")
assert response.status_code == 200
body = response.json()
assert body["status"] == "ready"
assert body["checks"]["chat"] == "disabled"
def test_ready_database_down_is_unavailable(monkeypatch) -> None:
patch_dependencies(monkeypatch, database=False)
with build_client(monkeypatch) as client:
response = client.get("/ready")
assert response.status_code == 503
body = response.json()
assert body["status"] == "unavailable"
assert body["checks"]["database"] == "fail"
def test_ready_bm25_missing_is_degraded(monkeypatch) -> None:
patch_dependencies(monkeypatch, bm25="missing")
with build_client(monkeypatch) as client:
response = client.get("/ready")
assert response.status_code == 200
body = response.json()
assert body["status"] == "degraded"
assert body["checks"]["bm25"] == "missing"