Skip to content

Commit 1db3992

Browse files
committed
Public health and status pages
1 parent 85dd3c8 commit 1db3992

3 files changed

Lines changed: 201 additions & 9 deletions

File tree

src/service/routers/misc.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,37 @@
1-
from fastapi import APIRouter, Depends
2-
from fastapi.responses import FileResponse
1+
from fastapi import APIRouter, Depends, Request
2+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
33
from fastapi import HTTPException
44
from langsmith import Client as LangsmithClient
55
from pathlib import Path
66
from schema import Feedback, FeedbackResponse, UsageInfoResponse
77
from typing import Any, Annotated
88
from ..opey_session import OpeySession
99
from ..dependencies import get_opey_session
10+
from ..status_probes import get_cached_status, render_status_html
1011
from auth.session import session_cookie
1112
import logging
1213

1314
logger = logging.getLogger('opey.service.routers.misc')
1415

1516
router = APIRouter()
1617

17-
@router.get("/status")
18-
async def get_status() -> dict[str, Any]:
19-
"""Health check endpoint with usage information."""
18+
@router.get("/health")
19+
async def get_health() -> dict[str, Any]:
20+
"""Minimal liveness check. No dependency probes — safe for load-balancer polling."""
21+
return {"status": "ok"}
22+
2023

21-
status_info = {
22-
"status": "ok",
23-
}
24+
@router.get("/status")
25+
async def get_status(request: Request) -> Response:
26+
"""Public status page: reports component health. Cached to prevent DoS amplification.
2427
25-
return status_info
28+
Content-negotiates on Accept: returns HTML for browsers, JSON otherwise.
29+
"""
30+
status = await get_cached_status()
31+
accept = request.headers.get("accept", "")
32+
if "text/html" in accept and "application/json" not in accept:
33+
return HTMLResponse(render_status_html(status))
34+
return JSONResponse(status)
2635

2736
@router.get("/mermaid_diagram", dependencies=[Depends(session_cookie)])
2837
async def get_mermaid_diagram(opey_session: Annotated[OpeySession, Depends(get_opey_session)]) -> FileResponse:

src/service/service.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,5 +76,6 @@
7676
limiter.exempt(session.create_session)
7777
limiter.exempt(chat.stop_stream)
7878
limiter.exempt(misc.get_status)
79+
limiter.exempt(misc.get_health)
7980
limiter.exempt(misc.get_mermaid_diagram)
8081
limiter.exempt(misc.feedback)

src/service/status_probes.py

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"""Public /status endpoint helpers: dependency probes with TTL cache + single-flight lock."""
2+
import asyncio
3+
import html
4+
import logging
5+
import os
6+
import time
7+
import tomllib
8+
from pathlib import Path
9+
from typing import Any
10+
11+
import aiohttp
12+
13+
from .checkpointer import checkpointers
14+
from .mcp_tools_cache import get_mcp_tools
15+
from .redis_client import get_redis_client
16+
17+
logger = logging.getLogger("opey.service.status")
18+
19+
_PROBE_TIMEOUT_SEC = 2.0
20+
_CACHE_TTL_SEC = 15.0
21+
22+
_start_time_monotonic = time.monotonic()
23+
24+
_cache: dict[str, Any] | None = None
25+
_cache_expires: float = 0.0
26+
_lock = asyncio.Lock()
27+
28+
29+
async def _probe_obp() -> dict[str, Any]:
30+
base = os.getenv("OBP_BASE_URL")
31+
if not base:
32+
return {"up": False, "latency_ms": None}
33+
url = f"{base.rstrip('/')}/obp/v5.1.0/root"
34+
start = time.monotonic()
35+
up = False
36+
try:
37+
timeout = aiohttp.ClientTimeout(total=_PROBE_TIMEOUT_SEC)
38+
async with aiohttp.ClientSession(timeout=timeout) as session:
39+
async with session.get(url) as resp:
40+
up = resp.status < 500
41+
except Exception:
42+
up = False
43+
return {"up": up, "latency_ms": int((time.monotonic() - start) * 1000)}
44+
45+
46+
async def _probe_redis() -> dict[str, Any]:
47+
start = time.monotonic()
48+
up = False
49+
try:
50+
client = get_redis_client()
51+
up = bool(await asyncio.wait_for(client.ping(), timeout=_PROBE_TIMEOUT_SEC))
52+
except Exception:
53+
up = False
54+
return {"up": up, "latency_ms": int((time.monotonic() - start) * 1000)}
55+
56+
57+
async def _probe_checkpointer() -> dict[str, Any]:
58+
# AsyncSqliteSaver is populated during app lifespan startup; presence is a sufficient probe.
59+
return {"up": "aiosql" in checkpointers and checkpointers["aiosql"] is not None}
60+
61+
62+
async def _probe_mcp() -> dict[str, Any]:
63+
try:
64+
tools = get_mcp_tools()
65+
return {"up": True, "tool_count": len(tools)}
66+
except Exception:
67+
return {"up": False, "tool_count": 0}
68+
69+
70+
async def _probe_llm() -> dict[str, Any]:
71+
# Don't call the provider — just verify credentials are configured.
72+
# Calling would cost money and let a public endpoint amplify traffic to an LLM API.
73+
configured = any(os.getenv(k) for k in ("OPENAI_API_KEY", "ANTHROPIC_API_KEY"))
74+
return {"up": configured}
75+
76+
77+
def _get_version() -> str:
78+
try:
79+
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
80+
with open(pyproject, "rb") as f:
81+
data = tomllib.load(f)
82+
return data.get("tool", {}).get("poetry", {}).get("version") or "unknown"
83+
except Exception:
84+
return "unknown"
85+
86+
87+
async def _compute_status() -> dict[str, Any]:
88+
obp, redis_r, ck, mcp, llm = await asyncio.gather(
89+
_probe_obp(),
90+
_probe_redis(),
91+
_probe_checkpointer(),
92+
_probe_mcp(),
93+
_probe_llm(),
94+
)
95+
components = {
96+
"obp": obp,
97+
"redis": redis_r,
98+
"checkpointer": ck,
99+
"mcp": mcp,
100+
"llm": llm,
101+
}
102+
overall = "ok" if all(c.get("up") for c in components.values()) else "degraded"
103+
return {
104+
"overall": overall,
105+
"version": _get_version(),
106+
"uptime_seconds": int(time.monotonic() - _start_time_monotonic),
107+
"components": components,
108+
}
109+
110+
111+
async def get_cached_status() -> dict[str, Any]:
112+
"""Return cached status if fresh; otherwise probe dependencies under a single-flight lock."""
113+
global _cache, _cache_expires
114+
if _cache is not None and time.monotonic() < _cache_expires:
115+
return _cache
116+
async with _lock:
117+
if _cache is not None and time.monotonic() < _cache_expires:
118+
return _cache
119+
_cache = await _compute_status()
120+
_cache_expires = time.monotonic() + _CACHE_TTL_SEC
121+
return _cache
122+
123+
124+
def render_status_html(status: dict[str, Any]) -> str:
125+
"""Render the status payload as a small self-contained HTML page. Escapes all dynamic values."""
126+
overall = status.get("overall", "unknown")
127+
overall_class = "ok" if overall == "ok" else "degraded"
128+
version = html.escape(str(status.get("version", "unknown")))
129+
uptime = int(status.get("uptime_seconds", 0))
130+
131+
rows = []
132+
for name, data in status.get("components", {}).items():
133+
up = bool(data.get("up"))
134+
dot = "ok" if up else "down"
135+
label = "up" if up else "down"
136+
extras = []
137+
if "latency_ms" in data and data["latency_ms"] is not None:
138+
extras.append(f"{int(data['latency_ms'])} ms")
139+
if "tool_count" in data:
140+
extras.append(f"{int(data['tool_count'])} tools")
141+
extra_text = html.escape(" · ".join(extras)) if extras else ""
142+
rows.append(
143+
f'<tr><td><span class="dot {dot}"></span>{html.escape(name)}</td>'
144+
f'<td>{label}</td><td class="muted">{extra_text}</td></tr>'
145+
)
146+
rows_html = "\n".join(rows)
147+
148+
return f"""<!doctype html>
149+
<html lang="en">
150+
<head>
151+
<meta charset="utf-8">
152+
<title>Opey status</title>
153+
<meta name="robots" content="noindex">
154+
<style>
155+
body {{ font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; color: #222; }}
156+
h1 {{ margin-bottom: 0.25rem; }}
157+
.overall {{ display: inline-block; padding: 0.25rem 0.6rem; border-radius: 4px; font-weight: 600; }}
158+
.overall.ok {{ background: #e6f4ea; color: #137333; }}
159+
.overall.degraded {{ background: #fce8e6; color: #b3261e; }}
160+
table {{ width: 100%; border-collapse: collapse; margin-top: 1.5rem; }}
161+
th, td {{ text-align: left; padding: 0.5rem 0.25rem; border-bottom: 1px solid #eee; }}
162+
th {{ font-size: 0.85rem; text-transform: uppercase; color: #666; }}
163+
.dot {{ display: inline-block; width: 0.6rem; height: 0.6rem; border-radius: 50%; margin-right: 0.5rem; vertical-align: middle; }}
164+
.dot.ok {{ background: #137333; }}
165+
.dot.down {{ background: #b3261e; }}
166+
.muted {{ color: #666; font-size: 0.9rem; }}
167+
footer {{ margin-top: 2rem; font-size: 0.85rem; color: #666; }}
168+
</style>
169+
</head>
170+
<body>
171+
<h1>Opey status</h1>
172+
<p><span class="overall {overall_class}">{html.escape(overall)}</span></p>
173+
<table>
174+
<thead><tr><th>Component</th><th>State</th><th></th></tr></thead>
175+
<tbody>
176+
{rows_html}
177+
</tbody>
178+
</table>
179+
<footer>version {version} · uptime {uptime}s · cached up to {int(_CACHE_TTL_SEC)}s</footer>
180+
</body>
181+
</html>
182+
"""

0 commit comments

Comments
 (0)