Coverage for src/taipanstack/resilience/watchdogs/health_pinger.py: 100%
86 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-10-13 20:52 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-10-13 20:52 +0000
1"""
2Health pinger — proactively checks dependency health.
4Runs async health checks against registered targets. If a target
5becomes unhealthy the associated ``CircuitBreaker`` is opened
6preventively.
7"""
9import asyncio
10import logging
11import math
12from collections.abc import Awaitable, Callable, Sequence
13from dataclasses import dataclass
15from taipanstack.core.result import Err, Ok, Result
16from taipanstack.resilience.circuit_breaker import CircuitBreaker, CircuitState
17from taipanstack.resilience.watchdogs._base import BaseWatcher
19logger = logging.getLogger("taipanstack.resilience.watchdogs.health")
22@dataclass
23class HealthTarget:
24 """A dependency to be monitored by :class:`HealthPinger`.
26 Attributes:
27 name: Human-readable name for logging.
28 check: Async callable returning ``True`` if the target is
29 healthy, ``False`` otherwise.
30 circuit_breaker: Optional circuit breaker to open on failure.
32 """
34 name: str
35 check: Callable[[], Awaitable[bool]]
36 circuit_breaker: CircuitBreaker | None = None
39async def check_target(target: HealthTarget) -> Result[bool, Exception]:
40 """Run a single health check.
42 Args:
43 target: The target to check.
45 Returns:
46 ``Ok(True)`` if healthy, ``Ok(False)`` if unhealthy,
47 ``Err`` if the check itself raises.
49 """
50 try:
51 healthy = await target.check()
52 return Ok(healthy)
53 except Exception as exc:
54 return Err(exc)
57async def check_all(
58 targets: Sequence[HealthTarget],
59) -> Result[dict[str, bool], Exception]:
60 """Run health checks for all targets concurrently.
62 Args:
63 targets: Targets to check.
65 Returns:
66 ``Ok(dict)`` mapping target names to health status.
68 """
69 # Run all checks concurrently
70 check_results = await asyncio.gather(*(check_target(t) for t in targets))
72 results: dict[str, bool] = {}
73 for target, result in zip(targets, check_results, strict=True):
74 if isinstance(result, Ok):
75 results[target.name] = result.ok_value
76 else:
77 logger.warning(
78 "Health check for '%s' failed during aggregation: %s",
79 target.name,
80 result.err_value,
81 )
82 results[target.name] = False
83 return Ok(results)
86class HealthPinger(BaseWatcher):
87 """Background watcher that pings external dependencies.
89 For each registered ``HealthTarget``, calls its ``check``
90 coroutine on every cycle. If a target is unhealthy and has
91 an associated ``CircuitBreaker``, the breaker is opened
92 preventively.
94 Args:
95 targets: Dependencies to monitor.
96 interval: Seconds between ping cycles.
97 on_health_change: Optional callback ``(name, is_healthy)``.
99 Example:
100 >>> async def db_ping() -> bool:
101 ... return await pool.fetchval("SELECT 1") == 1
102 >>> pinger = HealthPinger(
103 ... targets=[HealthTarget("db", db_ping, breaker)],
104 ... )
105 >>> await pinger.start()
107 """
109 def __init__(
110 self,
111 *,
112 targets: Sequence[HealthTarget],
113 interval: float = 10.0,
114 on_health_change: Callable[[str, bool], None] | None = None,
115 ) -> None:
116 """Initialize the health pinger.
118 Args:
119 targets: Dependencies to monitor.
120 interval: Seconds between ping cycles.
121 on_health_change: Optional callback on status change.
123 """
124 if (
125 not isinstance(interval, (int, float))
126 or not math.isfinite(interval)
127 or interval <= 0
128 ):
129 raise ValueError("interval must be a finite positive number")
130 super().__init__(interval=interval)
131 self._targets = list(targets)
132 self._on_health_change = on_health_change
133 self._last_status: dict[str, bool] = {}
135 async def _process_target(self, target: HealthTarget) -> None:
136 """Process a single health target."""
137 result = await check_target(target)
139 if isinstance(result, Ok):
140 is_healthy = result.ok_value
141 else:
142 logger.warning(
143 "Health check for '%s' raised: %s",
144 target.name,
145 result.err_value,
146 )
147 is_healthy = False
149 self._update_target_status(target, is_healthy)
151 def _check_and_open_breaker(self, target: HealthTarget, is_healthy: bool) -> None:
152 """Open circuit breaker preventively on failure (always checked)."""
153 if (
154 not is_healthy
155 and target.circuit_breaker is not None
156 and target.circuit_breaker.state is not CircuitState.OPEN
157 ):
158 _force_open_breaker(target.circuit_breaker, target.name)
160 def _notify_health_change(self, target: HealthTarget, is_healthy: bool) -> None:
161 """Notify on health status change and log it."""
162 if self._on_health_change is not None:
163 self._on_health_change(target.name, is_healthy)
165 if is_healthy:
166 logger.info("Target '%s' is now healthy", target.name)
167 else:
168 logger.warning("Target '%s' is now unhealthy", target.name)
170 def _update_target_status(self, target: HealthTarget, is_healthy: bool) -> None:
171 """Update target status and handle side-effects."""
172 previous = self._last_status.get(target.name)
174 self._check_and_open_breaker(target, is_healthy)
176 if previous == is_healthy:
177 return
179 self._last_status[target.name] = is_healthy
181 self._notify_health_change(target, is_healthy)
183 async def _run(self) -> None:
184 """Execute a single health-check cycle concurrently."""
185 await asyncio.gather(*(self._process_target(t) for t in self._targets))
188def _simulate_failures_to_open(breaker: CircuitBreaker, synthetic: Exception) -> None:
189 max_attempts = breaker.config.failure_threshold + 5
190 attempts = 0
192 while breaker.state is not CircuitState.OPEN and attempts < max_attempts:
193 breaker._record_failure(synthetic)
194 attempts += 1
197def _force_state_to_open(breaker: CircuitBreaker) -> bool:
198 if breaker.state is CircuitState.OPEN:
199 return True
201 old_state = breaker.state
202 acquired = breaker._state.lock.acquire(timeout=0.1)
203 if not acquired:
204 return False
205 try:
206 breaker._state.state = CircuitState.OPEN
207 finally:
208 breaker._state.lock.release()
209 breaker._notify_state_change(old_state, CircuitState.OPEN)
210 return True
213def _force_open_breaker(breaker: CircuitBreaker, target_name: str) -> None:
214 """Force a circuit breaker into OPEN state.
216 Simulates enough failures to trip the breaker by recording
217 a synthetic exception.
219 Args:
220 breaker: The circuit breaker to trip.
221 target_name: Target name for logging context.
223 """
224 synthetic = ConnectionError(f"Health ping failed for '{target_name}'")
226 _simulate_failures_to_open(breaker, synthetic)
227 if not _force_state_to_open(breaker):
228 return
230 logger.warning(
231 "Circuit breaker '%s' opened preventively for target '%s'",
232 breaker.name,
233 target_name,
234 )