From 2e68c830213947f80686158675075986dc5dcc1d Mon Sep 17 00:00:00 2001 From: Richie Cahill Date: Mon, 15 Jun 2026 21:21:01 -0400 Subject: [PATCH] added health endpoints --- python/ebook_search/api/main.py | 3 +- python/ebook_search/api/routes/__init__.py | 2 + python/ebook_search/api/routes/health.py | 95 +++++++++++++++++ python/ebook_search/llm_interface.py | 30 ++++++ tests/test_ebook_search_health.py | 116 +++++++++++++++++++++ 5 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 python/ebook_search/api/routes/health.py create mode 100644 tests/test_ebook_search_health.py diff --git a/python/ebook_search/api/main.py b/python/ebook_search/api/main.py index a894ca3..0c5ebb4 100644 --- a/python/ebook_search/api/main.py +++ b/python/ebook_search/api/main.py @@ -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) diff --git a/python/ebook_search/api/routes/__init__.py b/python/ebook_search/api/routes/__init__.py index b1fc051..2fcda79 100644 --- a/python/ebook_search/api/routes/__init__.py +++ b/python/ebook_search/api/routes/__init__.py @@ -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", ] diff --git a/python/ebook_search/api/routes/health.py b/python/ebook_search/api/routes/health.py new file mode 100644 index 0000000..4a9203b --- /dev/null +++ b/python/ebook_search/api/routes/health.py @@ -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" diff --git a/python/ebook_search/llm_interface.py b/python/ebook_search/llm_interface.py index 39f443e..e573630 100644 --- a/python/ebook_search/llm_interface.py +++ b/python/ebook_search/llm_interface.py @@ -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): diff --git a/tests/test_ebook_search_health.py b/tests/test_ebook_search_health.py new file mode 100644 index 0000000..036bd5c --- /dev/null +++ b/tests/test_ebook_search_health.py @@ -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"