Coverage for src/data_api/api/routers/ping.py: 100%
35 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-13 19:28 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-13 19:28 +0000
1"""Readiness based on existing snapshots, without upstream probes."""
3from datetime import datetime, timezone
4from typing import Literal
6from fastapi import APIRouter, Response
8from data_api.api.schemas.ping import DatasetError, DatasetHealth, PingResponse
9from data_api.core.config import PREFETCH_ENDPOINTS
10from data_api.data.nthudata import DatasetState, Freshness
11from data_api.runtime import get_runtime
13router = APIRouter()
16def _build_dataset_health(state: DatasetState | None, now: float) -> DatasetHealth:
17 if state is None:
18 return DatasetHealth()
19 snapshot = state.snapshot
20 error = state.last_error
21 return DatasetHealth(
22 usable=state.usable,
23 freshness=state.freshness,
24 check_due=state.last_checked_at is None or now >= state.next_check,
25 version=snapshot.version if snapshot else None,
26 loaded_at=snapshot.loaded_at if snapshot else None,
27 published_at=snapshot.published_at if snapshot else None,
28 last_checked_at=state.last_checked_at,
29 last_refresh_attempt_at=state.last_refresh_attempt_at,
30 last_refresh_success_at=state.last_refresh_success_at,
31 last_error=(
32 DatasetError(category=error.category, at=error.at, status_code=error.status_code)
33 if error
34 else None
35 ),
36 )
39def _readiness_status(ready: bool, healthy: bool) -> Literal["ok", "degraded", "unavailable"]:
40 if not ready:
41 return "unavailable"
42 return "ok" if healthy else "degraded"
45@router.get("/ping", include_in_schema=False)
46async def ping(response: Response) -> PingResponse:
47 nthudata = get_runtime().datasets
48 now = nthudata.clock()
49 endpoints = {f"/{endpoint}" for endpoint in PREFETCH_ENDPOINTS} | nthudata.states.keys()
50 datasets = {
51 endpoint: _build_dataset_health(nthudata.states.get(endpoint), now)
52 for endpoint in sorted(endpoints)
53 }
55 ready = all(dataset.usable for dataset in datasets.values())
56 healthy = all(
57 dataset.freshness == Freshness.CURRENT and not dataset.check_due
58 for dataset in datasets.values()
59 )
60 response.status_code = 200 if ready else 503
61 response.headers["Cache-Control"] = "no-store"
62 return PingResponse(
63 status=_readiness_status(ready, healthy),
64 ready=ready,
65 checked_at=datetime.now(timezone.utc),
66 datasets=datasets,
67 )
70@router.head("/ping", include_in_schema=False)
71async def ping_head() -> Response:
72 response = Response(media_type="application/json")
73 await ping(response)
74 # An empty HEAD body must not advertise a zero-length GET representation.
75 del response.headers["Content-Length"]
76 return response