|
| 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