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.common import configure_logger
|
||||||
from python.ebook_search.api.bm25_tasks import cancel_bm25_refresh
|
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.api.web import STATIC_DIR
|
||||||
from python.ebook_search.bm25_corpus import ensure_bm25_corpus
|
from python.ebook_search.bm25_corpus import ensure_bm25_corpus
|
||||||
from python.ebook_search.config import load_config
|
from python.ebook_search.config import load_config
|
||||||
@@ -59,6 +59,7 @@ def create_app() -> FastAPI:
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
|
app.include_router(health_router)
|
||||||
app.include_router(page_router)
|
app.include_router(page_router)
|
||||||
app.include_router(search_router)
|
app.include_router(search_router)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"""EPUB search web route modules."""
|
"""EPUB search web route modules."""
|
||||||
|
|
||||||
from python.ebook_search.api.routes.admin import router as admin_router
|
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.page import router as page_router
|
||||||
from python.ebook_search.api.routes.search import router as search_router
|
from python.ebook_search.api.routes.search import router as search_router
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"admin_router",
|
"admin_router",
|
||||||
|
"health_router",
|
||||||
"page_router",
|
"page_router",
|
||||||
"search_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
|
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]]:
|
def embedding_vectors_from_response(body: object) -> list[list[float]]:
|
||||||
"""Extract embedding vectors from an OpenAI-compatible embedding response."""
|
"""Extract embedding vectors from an OpenAI-compatible embedding response."""
|
||||||
if not isinstance(body, dict):
|
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