Coverage for src/taipanstack/resilience/watchdogs/health_pinger.py: 100%
72 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 15:01 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 15:01 +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
11from collections.abc import Awaitable, Callable, Sequence
12from dataclasses import dataclass
14from taipanstack.core.result import Err, Ok, Result
15from taipanstack.resilience.circuit_breaker import CircuitBreaker, CircuitState
16from taipanstack.resilience.watchdogs._base import BaseWatcher
18logger = logging.getLogger("taipanstack.resilience.watchdogs.health")
21@dataclass
22class HealthTarget:
23 """A dependency to be monitored by :class:`HealthPinger`.
25 Attributes:
26 name: Human-readable name for logging.
27 check: Async callable returning ``True`` if the target is
28 healthy, ``False`` otherwise.
29 circuit_breaker: Optional circuit breaker to open on failure.
31 """
33 name: str
34 check: Callable[[], Awaitable[bool]]
35 circuit_breaker: CircuitBreaker | None = None
38async def check_target(target: HealthTarget) -> Result[bool, Exception]:
39 """Run a single health check.
41 Args:
42 target: The target to check.
44 Returns:
45 ``Ok(True)`` if healthy, ``Ok(False)`` if unhealthy,
46 ``Err`` if the check itself raises.
48 """
49 try:
50 healthy = await target.check()
51 return Ok(healthy)
52 except Exception as exc:
53 return Err(exc)
56async def check_all(
57 targets: Sequence[HealthTarget],
58) -> Result[dict[str, bool], Exception]:
59 """Run health checks for all targets concurrently.
61 Args:
62 targets: Targets to check.
64 Returns:
65 ``Ok(dict)`` mapping target names to health status.
67 """
68 # Run all checks concurrently
69 check_results = await asyncio.gather(*(check_target(t) for t in targets))
71 results: dict[str, bool] = {}
72 for target, result in zip(targets, check_results, strict=True):
73 if isinstance(result, Ok):
74 results[target.name] = result.ok_value
75 else:
76 logger.warning(
77 "Health check for '%s' failed during aggregation: %s",
78 target.name,
79 result.err_value,
80 )
81 results[target.name] = False
82 return Ok(results)
85class HealthPinger(BaseWatcher):
86 """Background watcher that pings external dependencies.
88 For each registered ``HealthTarget``, calls its ``check``
89 coroutine on every cycle. If a target is unhealthy and has
90 an associated ``CircuitBreaker``, the breaker is opened
91 preventively.
93 Args:
94 targets: Dependencies to monitor.
95 interval: Seconds between ping cycles.
96 on_health_change: Optional callback ``(name, is_healthy)``.
98 Example:
99 >>> async def db_ping() -> bool:
100 ... return await pool.fetchval("SELECT 1") == 1
101 >>> pinger = HealthPinger(
102 ... targets=[HealthTarget("db", db_ping, breaker)],
103 ... )
104 >>> await pinger.start()
106 """
108 def __init__(
109 self,
110 *,
111 targets: Sequence[HealthTarget],
112 interval: float = 10.0,
113 on_health_change: Callable[[str, bool], None] | None = None,
114 ) -> None:
115 """Initialize the health pinger.
117 Args:
118 targets: Dependencies to monitor.
119 interval: Seconds between ping cycles.
120 on_health_change: Optional callback on status change.
122 """
123 super().__init__(interval=interval)
124 self._targets = list(targets)
125 self._on_health_change = on_health_change
126 self._last_status: dict[str, bool] = {}
128 async def _process_target(self, target: HealthTarget) -> None:
129 """Process a single health target."""
130 result = await check_target(target)
132 if isinstance(result, Ok):
133 is_healthy = result.ok_value
134 else:
135 logger.warning(
136 "Health check for '%s' raised: %s",
137 target.name,
138 result.err_value,
139 )
140 is_healthy = False
142 self._update_target_status(target, is_healthy)
144 def _check_and_open_breaker(self, target: HealthTarget, is_healthy: bool) -> None:
145 """Open circuit breaker preventively on failure (always checked)."""
146 if (
147 not is_healthy
148 and target.circuit_breaker is not None
149 and target.circuit_breaker.state != CircuitState.OPEN
150 ):
151 _force_open_breaker(target.circuit_breaker, target.name)
153 def _notify_health_change(self, target: HealthTarget, is_healthy: bool) -> None:
154 """Notify on health status change and log it."""
155 if self._on_health_change is not None:
156 self._on_health_change(target.name, is_healthy)
158 if is_healthy:
159 logger.info("Target '%s' is now healthy", target.name)
160 else:
161 logger.warning("Target '%s' is now unhealthy", target.name)
163 def _update_target_status(self, target: HealthTarget, is_healthy: bool) -> None:
164 """Update target status and handle side-effects."""
165 previous = self._last_status.get(target.name)
167 self._check_and_open_breaker(target, is_healthy)
169 if previous == is_healthy:
170 return
172 self._last_status[target.name] = is_healthy
174 self._notify_health_change(target, is_healthy)
176 async def _run(self) -> None:
177 """Execute a single health-check cycle concurrently."""
178 await asyncio.gather(*(self._process_target(t) for t in self._targets))
181def _force_open_breaker(breaker: CircuitBreaker, target_name: str) -> None:
182 """Force a circuit breaker into OPEN state.
184 Simulates enough failures to trip the breaker by recording
185 a synthetic exception.
187 Args:
188 breaker: The circuit breaker to trip.
189 target_name: Target name for logging context.
191 """
192 synthetic = ConnectionError(f"Health ping failed for '{target_name}'")
194 # Record failures until the breaker opens, but cap it to avoid infinite loops
195 # if the circuit breaker's state is corrupted or mutated.
196 max_attempts = breaker.config.failure_threshold + 5
197 attempts = 0
199 while breaker.state != CircuitState.OPEN and attempts < max_attempts:
200 breaker._record_failure(synthetic)
201 attempts += 1
203 if breaker.state != CircuitState.OPEN:
204 # Force open if it didn't open normally
205 old_state = breaker.state
206 with breaker._state.lock:
207 breaker._state.state = CircuitState.OPEN
208 breaker._notify_state_change(old_state, CircuitState.OPEN)
210 logger.warning(
211 "Circuit breaker '%s' opened preventively for target '%s'",
212 breaker.name,
213 target_name,
214 )