Coverage for src / lexigram / contracts / core / health.py: 28%
67 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-19 05:41 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-19 05:41 +0800
1"""Health check types for Lexigram Framework."""
3from __future__ import annotations
5from dataclasses import dataclass, field
6from datetime import datetime
7from enum import StrEnum
8from typing import Any, Protocol, runtime_checkable
11class HealthCheckCategory(StrEnum):
12 """Categorises health checks by their role in the Kubernetes health model.
14 Attributes:
15 LIVENESS: Checks that the application is alive (not deadlocked).
16 Used by ``/health/live`` endpoints. Failure triggers a restart.
17 READINESS: Checks that the application is ready to accept traffic.
18 Used by ``/health/ready`` endpoints. Failure removes the instance
19 from the load-balancer rotation.
20 STARTUP: Checks that the application has finished its startup sequence.
21 Used by ``/health/startup`` endpoints. Succeeded once; after that
22 the probe switches to liveness/readiness.
23 """
25 LIVENESS = "liveness"
26 READINESS = "readiness"
27 STARTUP = "startup"
30class HealthStatus(StrEnum):
31 """Unified health status."""
33 HEALTHY = "healthy"
34 UNHEALTHY = "unhealthy"
35 DEGRADED = "degraded"
36 UNKNOWN = "unknown"
37 STARTING = "starting"
40@runtime_checkable
41class HealthCheckProtocol(Protocol):
42 """Protocol for health check capability.
44 Expected Behavior:
45 - health_check: MUST return a result within a reasonable timeout (~5s).
46 - health_check: SHOULD NOT raise exceptions; wrap errors in HealthCheckResult.
47 """
49 async def health_check(self, timeout: float = 5.0) -> HealthCheckResult: ...
52@dataclass(frozen=True)
53class HealthCheckResult:
54 """Result of a health check."""
56 component: str
57 status: HealthStatus = HealthStatus.HEALTHY
58 message: str | None = None
59 error: str | None = None
60 duration_ms: float = 0.0
61 details: dict[str, Any] | None = None
62 checked_at: datetime | None = None
63 category: HealthCheckCategory = HealthCheckCategory.READINESS
65 def to_dict(self) -> dict[str, Any]:
66 """Convert to dictionary format."""
67 result: dict[str, Any] = {
68 "component": self.component,
69 "category": self.category.value,
70 "status": str(self.status),
71 "duration_ms": round(self.duration_ms, 2),
72 }
73 if self.message:
74 result["message"] = self.message
75 if self.error:
76 result["error"] = self.error
77 if self.details:
78 result["details"] = self.details
79 if self.checked_at:
80 result["checked_at"] = self.checked_at.isoformat()
81 return result
83 def is_healthy(self) -> bool:
84 """Check if status is healthy."""
85 return self.status == HealthStatus.HEALTHY
87 def is_degraded(self) -> bool:
88 """Check if status is degraded."""
89 return self.status == HealthStatus.DEGRADED
92@dataclass(frozen=True)
93class AggregateHealthResult:
94 """Composite health result that collects checks from multiple components.
96 The aggregate status follows a ``worst-case`` rule:
97 * Any ``UNHEALTHY`` check → overall ``UNHEALTHY``
98 * Any ``DEGRADED`` check (no UNHEALTHY) → overall ``DEGRADED``
99 * All ``HEALTHY`` → overall ``HEALTHY``
100 * No checks registered → ``UNKNOWN``
101 """
103 components: list[HealthCheckResult] = field(default_factory=list)
105 @property
106 def status(self) -> HealthStatus:
107 """Overall status, computed from component checks."""
108 if not self.components:
109 return HealthStatus.UNKNOWN
110 if any(c.status == HealthStatus.UNHEALTHY for c in self.components):
111 return HealthStatus.UNHEALTHY
112 if any(c.status == HealthStatus.DEGRADED for c in self.components):
113 return HealthStatus.DEGRADED
114 return HealthStatus.HEALTHY
116 def is_healthy(self) -> bool:
117 """Return True only if all components are healthy."""
118 return self.status == HealthStatus.HEALTHY
120 def to_dict(self) -> dict[str, Any]:
121 """Convert to a dictionary suitable for a ``/health`` HTTP response."""
122 return {
123 "status": self.status.value,
124 "components": [c.to_dict() for c in self.components],
125 }
128@runtime_checkable
129class HealthCheckAggregatorProtocol(Protocol):
130 """Protocol for services that aggregate health checks from multiple providers.
132 Implementations collect :class:`HealthCheckProtocol` instances, run them
133 concurrently, and return a single :class:`AggregateHealthResult`.
134 """
136 def register(
137 self,
138 name: str,
139 check: HealthCheckProtocol,
140 *,
141 category: HealthCheckCategory = HealthCheckCategory.READINESS,
142 ) -> None:
143 """Register a named health check.
145 Args:
146 name: Unique component name for this check.
147 check: Object implementing :class:`HealthCheckProtocol`.
148 category: Probe category for this check. Defaults to
149 :class:`HealthCheckCategory.READINESS`.
150 """
151 ...
153 async def run_all(
154 self,
155 timeout: float = 5.0,
156 *,
157 category: HealthCheckCategory | None = None,
158 ) -> AggregateHealthResult:
159 """Run all registered checks concurrently and aggregate results.
161 Args:
162 timeout: Per-check timeout in seconds.
163 category: Optional probe category filter.
165 Returns:
166 :class:`AggregateHealthResult` containing all component results.
167 """
168 ...
170 async def run_liveness(self, timeout: float = 5.0) -> AggregateHealthResult:
171 """Run only liveness checks and aggregate results."""
172 ...
174 async def run_readiness(self, timeout: float = 5.0) -> AggregateHealthResult:
175 """Run only readiness checks and aggregate results."""
176 ...
178 async def run_startup(self, timeout: float = 5.0) -> AggregateHealthResult:
179 """Run only startup checks and aggregate results."""
180 ...
183# Canonical alias — infrastructure protocols should reference this name.
184# Identical to HealthCheckProtocol; provided as a mixin-friendly import.
185HealthCheckableProtocol = HealthCheckProtocol
187__all__ = [
188 "AggregateHealthResult",
189 "HealthCheckAggregatorProtocol",
190 "HealthCheckCategory",
191 "HealthCheckProtocol",
192 "HealthCheckResult",
193 "HealthCheckableProtocol",
194 "HealthStatus",
195]