added health endpoints
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user