Coverage for agentos/core/health.py: 0%
80 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 08:01 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 08:01 +0800
1"""
2Production health check with dependency liveness probes.
4Extends the basic health endpoint with:
5- Database connectivity check (async)
6- Redis connectivity check (async)
7- Component-level health status
9Usage:
10 from agentos.core.health import HealthChecker
11 checker = HealthChecker(db_url="...", redis_url="...")
12 status = await checker.check()
13"""
15from __future__ import annotations
17import asyncio
18import logging
19import time
20from dataclasses import dataclass, field
21from typing import Any, Dict, List, Optional
23logger = logging.getLogger(__name__)
26@dataclass
27class ComponentHealth:
28 name: str
29 status: str # "healthy" | "degraded" | "unhealthy"
30 latency_ms: float
31 error: Optional[str] = None
34@dataclass
35class HealthReport:
36 status: str # "healthy" | "degraded" | "unhealthy"
37 uptime_seconds: float
38 components: Dict[str, ComponentHealth] = field(default_factory=dict)
39 timestamp: float = field(default_factory=time.time)
42class HealthChecker:
43 """Async health checker with component-level probing."""
45 def __init__(
46 self,
47 start_time: float,
48 db_url: Optional[str] = None,
49 redis_url: Optional[str] = None,
50 ):
51 self.start_time = start_time
52 self.db_url = db_url
53 self.redis_url = redis_url
55 async def _probe(self, name: str, check_fn, timeout: float = 3.0) -> ComponentHealth:
56 """Run a single component health probe with timeout."""
57 t0 = time.perf_counter()
58 try:
59 await asyncio.wait_for(check_fn(), timeout=timeout)
60 latency = (time.perf_counter() - t0) * 1000
61 return ComponentHealth(name=name, status="healthy", latency_ms=latency)
62 except asyncio.TimeoutError:
63 latency = (time.perf_counter() - t0) * 1000
64 return ComponentHealth(
65 name=name, status="unhealthy", latency_ms=latency,
66 error=f"Timeout after {timeout}s"
67 )
68 except Exception as e:
69 latency = (time.perf_counter() - t0) * 1000
70 return ComponentHealth(
71 name=name, status="unhealthy", latency_ms=latency,
72 error=str(e)
73 )
75 async def check(self) -> HealthReport:
76 """Run a full health check across all configured components."""
77 probes = []
79 # DB probe
80 if self.db_url:
81 probes.append(
82 self._probe("database", self._check_db)
83 )
85 # Redis probe
86 if self.redis_url:
87 probes.append(
88 self._probe("redis", self._check_redis)
89 )
91 # Always probe disk (write test)
92 probes.append(
93 self._probe("disk", self._check_disk)
94 )
96 results = await asyncio.gather(*probes, return_exceptions=True)
98 components: Dict[str, ComponentHealth] = {}
99 overall = "healthy"
101 for r in results:
102 if isinstance(r, ComponentHealth):
103 components[r.name] = r
104 if r.status == "unhealthy":
105 if overall == "healthy":
106 overall = "degraded"
107 elif r.status == "degraded" and overall == "healthy":
108 overall = "degraded"
109 elif isinstance(r, Exception):
110 # Probe itself crashed
111 components["internal"] = ComponentHealth(
112 name="internal", status="unhealthy",
113 latency_ms=0, error=str(r)
114 )
115 overall = "unhealthy"
117 return HealthReport(
118 status=overall,
119 uptime_seconds=time.time() - self.start_time,
120 components=components,
121 )
123 async def _check_db(self):
124 """Database connectivity probe."""
125 from sqlalchemy.ext.asyncio import create_async_engine
126 from sqlalchemy import text
128 engine = create_async_engine(self.db_url, echo=False)
129 async with engine.connect() as conn:
130 await conn.execute(text("SELECT 1"))
131 await engine.dispose()
133 async def _check_redis(self):
134 """Redis connectivity probe."""
135 import redis.asyncio as redis
137 r = redis.from_url(self.redis_url)
138 await r.ping()
139 await r.close()
141 async def _check_disk(self):
142 """Filesystem write test."""
143 import tempfile
144 import os
146 with tempfile.NamedTemporaryFile(delete=False, prefix="health_", suffix=".tmp") as f:
147 f.write(b"ok")
149 try:
150 os.unlink(f.name)
151 except OSError:
152 pass
155__all__ = ["HealthChecker", "HealthReport", "ComponentHealth"]