Skip to content

Commit 7899699

Browse files
SkylarKeltyclaude
andcommitted
Improve /health to probe SearXNG and LLM connectivity
Instead of always returning "healthy", the endpoint now makes lightweight HTTP probes to SearXNG (GET /) and the LLM backend (GET /models, only when summary is enabled). Returns per-check status and overall "healthy" or "degraded". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 59acc53 commit 7899699

File tree

2 files changed

+58
-10
lines changed

2 files changed

+58
-10
lines changed

artemis/main.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
from collections.abc import AsyncGenerator
2424
from contextlib import asynccontextmanager
2525

26+
import httpx
27+
2628
from fastapi import Depends, FastAPI, HTTPException, Request, status
2729
from fastapi.middleware.cors import CORSMiddleware
2830
from fastapi.responses import JSONResponse, StreamingResponse
@@ -491,16 +493,41 @@ async def root() -> dict[str, str]:
491493

492494
@app.get("/health")
493495
async def health() -> dict[str, object]:
494-
"""Health check endpoint.
496+
"""Health check endpoint with dependency probes.
495497
496-
Returns service status and configuration info including:
497-
- Whether summarization is enabled
498-
- Whether authentication is required
499-
- Whether the summary circuit is open (due to failures)
498+
Probes SearXNG connectivity and (if summary is enabled) the LLM
499+
backend. Returns per-check status and an overall status of
500+
"healthy" or "degraded".
500501
"""
501502
settings = get_settings()
503+
checks: dict[str, str] = {}
504+
505+
# Probe SearXNG
506+
try:
507+
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
508+
resp = await client.get(f"{settings.searxng_api_base}/")
509+
checks["searxng"] = "ok" if resp.status_code < 400 else f"http_{resp.status_code}"
510+
except Exception as exc:
511+
checks["searxng"] = f"error: {type(exc).__name__}"
512+
513+
# Probe LLM backend (only if summarization is enabled)
514+
if settings.enable_summary:
515+
try:
516+
headers: dict[str, str] = {}
517+
if settings.litellm_api_key:
518+
headers["Authorization"] = f"Bearer {settings.litellm_api_key}"
519+
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
520+
resp = await client.get(
521+
f"{settings.litellm_base_url}/models", headers=headers,
522+
)
523+
checks["llm"] = "ok" if resp.status_code < 400 else f"http_{resp.status_code}"
524+
except Exception as exc:
525+
checks["llm"] = f"error: {type(exc).__name__}"
526+
527+
all_ok = all(v == "ok" for v in checks.values())
502528
return {
503-
"status": "healthy",
529+
"status": "healthy" if all_ok else "degraded",
530+
"checks": checks,
504531
"summary_enabled": settings.enable_summary,
505532
"auth_enabled": bool(settings.artemis_api_key),
506533
"summary_circuit_open": summary_circuit.opened_until > time.time(),

tests/test_api_extended.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,23 +81,44 @@ def test_root_returns_message(self) -> None:
8181
self.assertIn("message", response.json())
8282
self.assertIn("Artemis", response.json()["message"])
8383

84-
def test_health_returns_status(self) -> None:
84+
@patch("artemis.main.httpx.AsyncClient")
85+
def test_health_returns_status(self, mock_client_cls: MagicMock) -> None:
86+
mock_resp = MagicMock(status_code=200)
87+
mock_ctx = AsyncMock()
88+
mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx)
89+
mock_ctx.__aexit__ = AsyncMock(return_value=False)
90+
mock_ctx.get = AsyncMock(return_value=mock_resp)
91+
mock_client_cls.return_value = mock_ctx
92+
8593
response = self.client.get("/health")
8694
self.assertEqual(response.status_code, 200)
8795
body = response.json()
88-
self.assertEqual(body["status"], "healthy")
96+
self.assertIn(body["status"], ("healthy", "degraded"))
97+
self.assertIn("checks", body)
8998
self.assertIn("summary_enabled", body)
9099
self.assertIn("auth_enabled", body)
91100
self.assertIn("summary_circuit_open", body)
92101

93102
def test_health_reports_circuit_open(self) -> None:
94103
summary_circuit.opened_until = time.time() + 300
95-
response = self.client.get("/health")
104+
with patch("artemis.main.httpx.AsyncClient") as mock_cls:
105+
mock_ctx = AsyncMock()
106+
mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx)
107+
mock_ctx.__aexit__ = AsyncMock(return_value=False)
108+
mock_ctx.get = AsyncMock(return_value=MagicMock(status_code=200))
109+
mock_cls.return_value = mock_ctx
110+
response = self.client.get("/health")
96111
self.assertTrue(response.json()["summary_circuit_open"])
97112

98113
def test_health_reports_circuit_closed(self) -> None:
99114
summary_circuit.opened_until = 0.0
100-
response = self.client.get("/health")
115+
with patch("artemis.main.httpx.AsyncClient") as mock_cls:
116+
mock_ctx = AsyncMock()
117+
mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx)
118+
mock_ctx.__aexit__ = AsyncMock(return_value=False)
119+
mock_ctx.get = AsyncMock(return_value=MagicMock(status_code=200))
120+
mock_cls.return_value = mock_ctx
121+
response = self.client.get("/health")
101122
self.assertFalse(response.json()["summary_circuit_open"])
102123

103124

0 commit comments

Comments
 (0)