"""Health endpoint — handbook contract (10-platform/health.md).
`status` is the aggregate of the sub-checks (worst of: down > degraded > ok).
Embed/hive failures degrade the service, they don't take it down. The body
keeps the pre-contract `embed`/`hive` payloads at the top level so existing
in-house tooling keeps working (monitors ignore unknown fields).
"""
import time
from fastapi import APIRouter
from navi.config import settings
router = APIRouter(tags=["health"])
# The embed check is a real network call — polls come in every ~30 s, so the
# result is cached short (handbook: cache at most 10 s).
_EMBED_CACHE_TTL = 10.0
_embed_cache: dict = {"checked_at": 0.0, "result": None}
@router.get("/health")
async def health() -> dict:
embed_status = await _check_embed_cached()
hive = _hive_status()
embed_check = _embed_check_entry(embed_status)
hive_check = _hive_check_entry(hive)
statuses = [check["status"] for check in (embed_check, hive_check)]
aggregate = "degraded" if "degraded" in statuses else "ok"
return {
"status": aggregate,
"service": "navi",
"checks": {"embed": embed_check, "hive": hive_check},
# Legacy payloads (pre-contract) — kept for existing tooling.
"embed": embed_status,
"hive": hive,
}
def _embed_check_entry(embed_status: dict) -> dict:
"""Map the legacy embed probe to a handbook-shaped check entry."""
if embed_status.get("ok"):
check = {"status": "ok"}
if embed_status.get("ms") is not None:
check["ms"] = embed_status["ms"]
return check
return {
"status": "degraded",
"detail": embed_status.get("error") or "no_embedding_backend",
}
def _hive_check_entry(hive: dict) -> dict:
"""Map the swarm registry status to a handbook-shaped check entry."""
if not hive.get("configured", False):
return {"status": "ok", "detail": "not configured"}
if hive.get("error"):
return {"status": "degraded", "detail": hive["error"]}
if not hive.get("reachable", False):
detail = hive.get("last_error") or "hive unreachable"
return {"status": "degraded", "detail": detail}
return {"status": "ok"}
@router.get("/health/embed")
async def health_embed() -> dict:
return await _check_embed_cached()
def _hive_status() -> dict:
"""Swarm registry (hive) reachability as seen by the announce loop."""
if not settings.hive_url:
return {"configured": False}
from navi.swarm import get_announcer
announcer = get_announcer()
if announcer is None:
return {"configured": False, "error": "swarm key missing - announce disabled"}
return announcer.get_status()
async def _check_embed_cached() -> dict:
"""Embed self-check, cached for up to 10 s (health polls every ~30 s)."""
now = time.monotonic()
if (
_embed_cache["result"] is not None
and now - _embed_cache["checked_at"] < _EMBED_CACHE_TTL
):
return _embed_cache["result"]
started = time.perf_counter()
result = await _check_embed()
elapsed_ms = int((time.perf_counter() - started) * 1000)
result.setdefault("ms", None)
result["ms"] = elapsed_ms
_embed_cache["checked_at"] = now
_embed_cache["result"] = result
return result
async def _check_embed() -> dict:
"""One embed call against the active backend; never raises."""
backend = None
try:
from navi.api.deps import get_memory_store
mem = get_memory_store()
backend = getattr(mem, "_embedding_backend", None)
if backend is None:
return {"ok": False, "backend": "none", "error": "no_embedding_backend"}
await backend.embed(["test"], model=settings.embedding_model)
return {"ok": True, "backend": getattr(backend, "_host", "unknown"), "error": None}
except Exception as e:
host = getattr(backend, "_host", "unknown") if backend else "none"
return {"ok": False, "backend": host, "error": str(e)}