Coverage for src/taipanstack/resilience/circuit_breaker.py: 100%
329 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"""
2Circuit Breaker pattern implementation.
4Provides protection against cascading failures by temporarily
5blocking calls to a failing service. Compatible with any
6Python framework (sync and async).
7"""
9import functools
10import inspect
11import logging
12import math
13import threading
14import time
15from collections.abc import Awaitable, Callable
16from dataclasses import dataclass, field
17from enum import Enum
18from typing import ParamSpec, Protocol, TypeGuard, TypeVar, cast, overload
20from taipanstack.core.result import Err
22P = ParamSpec("P")
23R = TypeVar("R")
26class CircuitBreakerDecorator(Protocol):
27 """Protocol for the circuit breaker decorator."""
29 @overload
30 def __call__(self, func: Callable[P, R]) -> Callable[P, R]: ...
32 @overload
33 def __call__(
34 self,
35 func: Callable[P, Awaitable[R]],
36 ) -> Callable[P, Awaitable[R]]: ...
39logger = logging.getLogger("taipanstack.resilience.circuit_breaker")
41try:
42 import structlog as _structlog
44 _structlog_logger = _structlog.get_logger("taipanstack.resilience.circuit_breaker")
45 _HAS_STRUCTLOG = True
46except ImportError:
47 _structlog_logger = None
48 _HAS_STRUCTLOG = False
51class CircuitState(Enum):
52 """States of the circuit breaker."""
54 CLOSED = "closed" # Normal operation, requests flow through
55 OPEN = "open" # Circuit is tripped, requests are blocked
56 HALF_OPEN = "half_open" # Testing if service has recovered
59class CircuitBreakerError(Exception):
60 """Raised when circuit breaker is open."""
62 def __init__(self, message: str, state: CircuitState) -> None:
63 """Initialize CircuitBreakerError.
65 Args:
66 message: Error description.
67 state: Current circuit state.
69 """
70 self.state = state
71 super().__init__(message)
74@dataclass(frozen=True)
75class CircuitBreakerConfig:
76 """Configuration for circuit breaker behavior.
78 Attributes:
79 failure_threshold: Number of failures before opening circuit.
80 success_threshold: Successes needed in half-open to close.
81 timeout: Seconds before trying half-open after open.
82 excluded_exceptions: Exceptions that don't count as failures.
83 failure_exceptions: Exceptions that count as failures.
85 """
87 failure_threshold: int = 5
88 success_threshold: int = 2
89 timeout: float = 30.0
90 excluded_exceptions: tuple[type[Exception], ...] = ()
91 failure_exceptions: tuple[type[Exception], ...] = (Exception,)
93 def _check_finite(self, value: float, name: str) -> None:
94 if not math.isfinite(value):
95 raise ValueError(f"{name} must be finite")
97 def __post_init__(self) -> None:
98 """Validate configuration values."""
99 self._check_finite(self.failure_threshold, "failure_threshold")
100 self._check_finite(self.success_threshold, "success_threshold")
101 self._check_finite(self.timeout, "timeout")
104@dataclass
105class CircuitBreakerState:
106 """Internal state tracking for circuit breaker."""
108 state: CircuitState = CircuitState.CLOSED
109 failure_count: int = 0
110 success_count: int = 0
111 half_open_attempts: int = 0
112 last_failure_time: float = 0.0
113 lock: threading.Lock = field(default_factory=threading.Lock)
116class CircuitBreaker:
117 """Circuit breaker implementation.
119 Monitors function calls and opens the circuit when too many
120 failures occur, preventing further calls until the service
121 recovers. Supports both sync and async functions.
123 Example:
124 >>> breaker = CircuitBreaker(failure_threshold=3)
125 >>> @breaker
126 ... def call_external_api():
127 ... return requests.get("https://api.example.com", timeout=10)
129 """
131 @staticmethod
132 def _is_valid_metric(value: object, min_val: float = 0) -> TypeGuard[int | float]:
133 """Check if metric is a valid finite number >= min_val."""
134 return (
135 isinstance(value, (int, float))
136 and math.isfinite(value)
137 and value >= min_val
138 )
140 @staticmethod
141 def _get_safe_threshold(value: object, min_val: float, default: float) -> float:
142 """Return the threshold if valid, otherwise return the default."""
143 if CircuitBreaker._is_valid_metric(value, min_val):
144 return float(value)
145 return default
147 @staticmethod
148 def _check_finite_val(value: float, min_val: float, err_msg: str) -> None:
149 if not math.isfinite(value) or value < min_val:
150 raise ValueError(err_msg)
152 @staticmethod
153 def _validate_thresholds(
154 timeout: float,
155 failure_threshold: int,
156 success_threshold: int,
157 ) -> None:
158 CircuitBreaker._check_finite_val(
159 timeout,
160 0,
161 "timeout must be a finite non-negative number",
162 )
163 CircuitBreaker._check_finite_val(
164 failure_threshold,
165 1,
166 "failure_threshold must be a finite number >= 1",
167 )
168 CircuitBreaker._check_finite_val(
169 success_threshold,
170 1,
171 "success_threshold must be a finite number >= 1",
172 )
174 def __init__(
175 self,
176 *,
177 failure_threshold: int = 5,
178 success_threshold: int = 2,
179 timeout: float = 30.0,
180 excluded_exceptions: tuple[type[Exception], ...] = (),
181 failure_exceptions: tuple[type[Exception], ...] = (Exception,),
182 name: str = "default",
183 on_state_change: Callable[[CircuitState, CircuitState], None] | None = None,
184 ) -> None:
185 """Initialize CircuitBreaker.
187 Args:
188 failure_threshold: Failures before opening circuit.
189 success_threshold: Successes to close from half-open.
190 timeout: Seconds before attempting half-open.
191 excluded_exceptions: Exceptions that don't trip circuit.
192 failure_exceptions: Exceptions that count as failures.
193 name: Name for logging/identification.
194 on_state_change: Optional callback invoked on state transitions
195 with (old_state, new_state). Useful for custom monitoring.
197 """
198 CircuitBreaker._validate_thresholds(
199 timeout,
200 failure_threshold,
201 success_threshold,
202 )
204 self.config = CircuitBreakerConfig(
205 failure_threshold=failure_threshold,
206 success_threshold=success_threshold,
207 timeout=timeout,
208 excluded_exceptions=excluded_exceptions,
209 failure_exceptions=failure_exceptions,
210 )
211 self.name = name
212 self._state = CircuitBreakerState()
213 self._on_state_change = on_state_change
215 @property
216 def state(self) -> CircuitState:
217 """Get current circuit state."""
218 return self._state.state
220 @property
221 def failure_count(self) -> int:
222 """Get current failure count."""
223 return self._state.failure_count
225 def _log_callback_failure(
226 self,
227 old_state: CircuitState,
228 new_state: CircuitState,
229 e: Exception,
230 ) -> None:
231 if _HAS_STRUCTLOG and _structlog_logger is not None:
232 _structlog_logger.error(
233 "circuit_state_change_callback_failed",
234 circuit=self.name,
235 old_state=old_state.value,
236 new_state=new_state.value,
237 error=str(e),
238 )
239 else:
240 logger.error(
241 "Circuit %s state change callback failed: %s",
242 self.name,
243 str(e),
244 )
246 def _log_structlog_warning(
247 self,
248 old_state: CircuitState,
249 new_state: CircuitState,
250 ) -> None:
251 if _HAS_STRUCTLOG and _structlog_logger is not None:
252 _structlog_logger.warning(
253 "circuit_state_changed",
254 circuit=self.name,
255 old_state=old_state.value,
256 new_state=new_state.value,
257 failure_count=self._state.failure_count,
258 )
260 def _notify_state_change(
261 self,
262 old_state: CircuitState,
263 new_state: CircuitState,
264 ) -> None:
265 """Notify callback of state transition if registered.
267 Emit a structured log via structlog when no callback is provided
268 and structlog is available.
269 """
270 if self._on_state_change is not None:
271 try:
272 self._on_state_change(old_state, new_state)
273 except Exception as e:
274 self._log_callback_failure(old_state, new_state, e)
275 return
277 self._log_structlog_warning(old_state, new_state)
279 def _get_safe_timeout(self) -> float:
280 try:
281 return float(self.config.timeout)
282 except (TypeError, ValueError):
283 return 30.0
285 def _calculate_elapsed_time(self, now: float) -> float | None:
286 """Calculate time elapsed since last failure."""
287 if not isinstance(self._state.last_failure_time, (int, float)):
288 return None # type: ignore[unreachable]
290 safe_timeout = self._get_safe_timeout()
292 if not math.isfinite(self._state.last_failure_time):
293 return safe_timeout
295 elapsed = now - float(self._state.last_failure_time)
297 # Safe check against NaN and Inf time corruption
298 # If elapsed < 0, a backward clock jump occurred. We should
299 # allow a transition to prevent permanent lockout.
300 if elapsed < 0:
301 return safe_timeout
302 return elapsed
304 def _transition_to_half_open(
305 self,
306 elapsed: float,
307 ) -> tuple[bool, tuple[CircuitState, CircuitState] | None]:
308 """Transition the circuit to half-open state."""
309 # Before transitioning, verify if we can make an attempt
310 # This happens in a lock, so it's thread-safe. However, once
311 # the state changes to HALF_OPEN, subsequent threads in the
312 # same lock block will hit the HALF_OPEN case.
313 self._state.state = CircuitState.HALF_OPEN
314 self._state.success_count = 0
315 # Initialize half_open_attempts to 1 because this first call
316 # that transitions the state is also an attempt.
317 self._state.half_open_attempts = 1
318 logger.info(
319 "Circuit %s entering half-open state (was open for %.1fs, failures=%d)",
320 self.name,
321 elapsed,
322 self._state.failure_count,
323 )
324 return True, (CircuitState.OPEN, CircuitState.HALF_OPEN)
326 def _evaluate_open_timeout(
327 self,
328 elapsed: float,
329 ) -> tuple[bool, tuple[CircuitState, CircuitState] | None]:
330 timeout = self._get_safe_threshold(self.config.timeout, 0, 30.0)
331 if elapsed >= timeout:
332 return self._transition_to_half_open(elapsed)
333 return False, None
335 def _get_valid_elapsed(self) -> float | None:
336 try:
337 now = time.monotonic()
338 except Exception:
339 return None
340 if not math.isfinite(now):
341 return None
342 return self._calculate_elapsed_time(now)
344 def _handle_open_state(
345 self,
346 ) -> tuple[bool, tuple[CircuitState, CircuitState] | None]:
347 """Handle logic for OPEN state in _should_attempt."""
348 elapsed = self._get_valid_elapsed()
349 if elapsed is None:
350 return False, None
351 return self._evaluate_open_timeout(elapsed)
353 def _handle_attempt_half_open(self) -> bool:
354 if not self._is_valid_metric(self._state.half_open_attempts):
355 return False
357 success_threshold = self._get_safe_threshold(
358 self.config.success_threshold,
359 1,
360 2,
361 )
363 if self._state.half_open_attempts < success_threshold:
364 self._state.half_open_attempts += 1
365 return True
366 return False
368 def _evaluate_state_for_attempt(
369 self,
370 ) -> tuple[bool, tuple[CircuitState, CircuitState] | None]:
371 state = self._state.state
372 if state == CircuitState.CLOSED:
373 return True, None
374 if state == CircuitState.OPEN:
375 return self._handle_open_state()
376 if state == CircuitState.HALF_OPEN:
377 return self._handle_attempt_half_open(), None
378 return False, None # type: ignore[unreachable]
380 def _should_attempt(self) -> bool:
381 """Check if a call should be attempted."""
382 try:
383 with self._state.lock:
384 should_attempt, state_change = self._evaluate_state_for_attempt()
385 except Exception:
386 # If lock acquisition fails, fail-safe by preventing the call.
387 return False
389 if state_change:
390 self._notify_state_change(*state_change)
392 return should_attempt
394 def _handle_success_half_open(self) -> tuple[CircuitState, CircuitState] | None:
395 if not self._is_valid_metric(self._state.success_count):
396 # Type corruption detected, reset and increment
397 self._state.success_count = 1
398 else:
399 self._state.success_count += 1
401 success_threshold = self._get_safe_threshold(
402 self.config.success_threshold,
403 1,
404 2,
405 )
407 if self._state.success_count >= success_threshold:
408 self._state.state = CircuitState.CLOSED
409 self._state.failure_count = 0
410 self._state.half_open_attempts = 0
411 logger.info(
412 "Circuit %s closed after recovery (%d consecutive successes)",
413 self.name,
414 self._state.success_count,
415 )
416 return (CircuitState.HALF_OPEN, CircuitState.CLOSED)
417 return None
419 def _get_success_state_change(self) -> tuple[CircuitState, CircuitState] | None:
420 state = self._state.state
421 if state == CircuitState.HALF_OPEN:
422 return self._handle_success_half_open()
423 if state == CircuitState.CLOSED:
424 # Reset failure count on success
425 self._state.failure_count = 0
426 return None
427 return None
429 def _record_success(self) -> None:
430 """Record a successful call."""
431 try:
432 with self._state.lock:
433 state_change = self._get_success_state_change()
434 except Exception:
435 return
437 if state_change:
438 self._notify_state_change(*state_change)
440 def _handle_failure_half_open(self) -> tuple[CircuitState, CircuitState] | None:
441 """Handle failure when in HALF_OPEN state."""
442 self._state.state = CircuitState.OPEN
443 self._state.half_open_attempts = 0
444 logger.warning(
445 "Circuit %s reopened after failure in half-open",
446 self.name,
447 )
448 return (CircuitState.HALF_OPEN, CircuitState.OPEN)
450 def _handle_failure_closed(self) -> tuple[CircuitState, CircuitState] | None:
451 """Handle failure when in CLOSED state."""
452 # Check against corrupted NaN/Inf failure_count
453 if not self._is_valid_metric(self._state.failure_count):
454 self._state.state = CircuitState.OPEN
455 logger.warning(
456 "Circuit %s opened due to state/type corruption in failure_count",
457 self.name,
458 )
459 return (CircuitState.CLOSED, CircuitState.OPEN)
461 failure_threshold = self._get_safe_threshold(
462 self.config.failure_threshold,
463 1,
464 5,
465 )
467 if self._state.failure_count >= failure_threshold:
468 self._state.state = CircuitState.OPEN
469 logger.warning(
470 "Circuit %s opened after %d failures (threshold=%d)",
471 self.name,
472 self._state.failure_count,
473 failure_threshold,
474 )
475 return (CircuitState.CLOSED, CircuitState.OPEN)
477 return None
479 def _increment_failure_count(self) -> None:
480 if not self._is_valid_metric(self._state.failure_count):
481 # Handle type mutation (e.g. failure_count became string) or Inf/NaN
482 # Safe degradation: reset to max so it opens immediately
483 self._state.failure_count = self.config.failure_threshold
484 else:
485 self._state.failure_count += 1
487 def _update_failure_metrics(self) -> None:
488 self._increment_failure_count()
490 try:
491 now = time.monotonic()
492 except Exception:
493 now = float("nan")
495 if math.isfinite(now):
496 self._state.last_failure_time = now
498 def _get_failure_state_change(self) -> tuple[CircuitState, CircuitState] | None:
499 """Get state change for a failure."""
500 if self._state.state == CircuitState.HALF_OPEN:
501 return self._handle_failure_half_open()
502 if self._state.state == CircuitState.CLOSED:
503 return self._handle_failure_closed()
504 return None
506 def _is_excluded_exception(self, exc: Exception) -> bool:
507 try:
508 return isinstance(exc, self.config.excluded_exceptions)
509 except TypeError:
510 return False
512 def _record_failure(self, exc: Exception) -> None:
513 """Record a failed call."""
514 if self._is_excluded_exception(exc):
515 return
517 state_change: tuple[CircuitState, CircuitState] | None = None
519 try:
520 with self._state.lock:
521 self._update_failure_metrics()
522 state_change = self._get_failure_state_change()
523 except Exception:
524 return
526 if state_change:
527 self._notify_state_change(*state_change)
529 def reset(self) -> None:
530 """Reset circuit breaker to closed state."""
531 try:
532 with self._state.lock:
533 self._state.state = CircuitState.CLOSED
534 self._state.failure_count = 0
535 self._state.success_count = 0
536 self._state.half_open_attempts = 0
537 logger.info("Circuit %s manually reset", self.name)
538 except Exception:
539 return
541 def _is_failure_exception(self, exc: Exception) -> bool:
542 try:
543 return isinstance(exc, self.config.failure_exceptions)
544 except TypeError:
545 return True
547 def _process_result(self, result: R) -> R:
548 """Process Result outcome and record success/failure.
550 Args:
551 result: The result to process.
553 Returns:
554 The original result.
556 """
557 if isinstance(result, Err):
558 err_val = result.unwrap_err()
559 if self._is_failure_exception(err_val):
560 self._record_failure(err_val)
561 return result
562 # Ignored exception in Result monad
563 return result
564 self._record_success()
565 return result
567 def _is_valid_half_open_attempts(self) -> bool:
568 """Validate half-open attempts value against corruption."""
569 return (
570 isinstance(self._state.half_open_attempts, (int, float))
571 and math.isfinite(self._state.half_open_attempts)
572 and self._state.half_open_attempts >= 0
573 )
575 def _safe_decrement_half_open_attempts(self) -> None:
576 """Safely decrement half-open attempts."""
577 if self._state.state != CircuitState.HALF_OPEN:
578 return
580 if not self._is_valid_half_open_attempts():
581 # Reset if state is corrupted to prevent crash
582 self._state.half_open_attempts = 0
583 return
585 if self._state.half_open_attempts > 0:
586 self._state.half_open_attempts -= 1
588 def _decrement_half_open(self, is_half_open: bool) -> None:
589 """Decrement half-open attempt count if applicable.
591 Args:
592 is_half_open: Whether the circuit was half-open before attempt.
594 """
595 if is_half_open:
596 try:
597 with self._state.lock:
598 self._safe_decrement_half_open_attempts()
599 except Exception:
600 return
602 def __call__(
603 self,
604 func: Callable[P, R] | Callable[P, Awaitable[R]],
605 ) -> Callable[P, R] | Callable[P, Awaitable[R]]:
606 """Decorate a sync or async function with circuit breaker protection."""
607 if inspect.iscoroutinefunction(func):
608 func_coro = cast(Callable[P, Awaitable[R]], func)
610 @functools.wraps(func_coro)
611 async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
612 if not self._should_attempt():
613 raise CircuitBreakerError(
614 f"Circuit {self.name} is open",
615 state=self._state.state,
616 )
618 is_half_open = self._state.state == CircuitState.HALF_OPEN
620 try:
621 result = await func_coro(*args, **kwargs)
622 return self._process_result(result)
623 except Exception as e:
624 try:
625 is_failure = isinstance(e, self.config.failure_exceptions)
626 except TypeError:
627 is_failure = True
628 if is_failure:
629 self._record_failure(e)
630 raise
631 raise
632 finally:
633 self._decrement_half_open(is_half_open)
635 return async_wrapper
637 func_sync = cast(Callable[P, R], func)
639 @functools.wraps(func_sync)
640 def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
641 if not self._should_attempt():
642 raise CircuitBreakerError(
643 f"Circuit {self.name} is open",
644 state=self._state.state,
645 )
647 is_half_open = self._state.state == CircuitState.HALF_OPEN
649 try:
650 result = func_sync(*args, **kwargs)
651 return self._process_result(result)
652 except Exception as e:
653 try:
654 is_failure = isinstance(e, self.config.failure_exceptions)
655 except TypeError:
656 is_failure = True
657 if is_failure:
658 self._record_failure(e)
659 raise
660 raise
661 finally:
662 self._decrement_half_open(is_half_open)
664 return wrapper
667def circuit_breaker(
668 *,
669 failure_threshold: int = 5,
670 success_threshold: int = 2,
671 timeout: float = 30.0,
672 excluded_exceptions: tuple[type[Exception], ...] = (),
673 failure_exceptions: tuple[type[Exception], ...] = (Exception,),
674 name: str | None = None,
675 on_state_change: Callable[[CircuitState, CircuitState], None] | None = None,
676) -> CircuitBreakerDecorator:
677 """Decorate a sync or async function with circuit breaker pattern.
679 Args:
680 failure_threshold: Failures before opening circuit.
681 success_threshold: Successes to close from half-open.
682 timeout: Seconds before attempting half-open.
683 excluded_exceptions: Exceptions that don't trip circuit.
684 failure_exceptions: Exceptions that count as failures.
685 name: Optional name for the circuit.
686 on_state_change: Optional callback invoked on state transitions
687 with (old_state, new_state).
689 Returns:
690 Decorated function with circuit breaker protection.
692 Example:
693 >>> @circuit_breaker(failure_threshold=3, timeout=60)
694 ... def call_api(endpoint: str) -> dict:
695 ... return requests.get(endpoint, timeout=10).json()
697 >>> @circuit_breaker(
698 ... failure_threshold=3,
699 ... on_state_change=lambda old, new: print(f"{old} -> {new}"),
700 ... )
701 ... def monitored_call() -> str:
702 ... return service.call()
704 """
706 def decorator(
707 func: Callable[P, R] | Callable[P, Awaitable[R]],
708 ) -> Callable[P, R] | Callable[P, Awaitable[R]]:
709 breaker = CircuitBreaker(
710 failure_threshold=failure_threshold,
711 success_threshold=success_threshold,
712 timeout=timeout,
713 excluded_exceptions=excluded_exceptions,
714 failure_exceptions=failure_exceptions,
715 name=name or cast(str, getattr(func, "__name__", "unknown")),
716 on_state_change=on_state_change,
717 )
718 return breaker(func)
720 return cast(CircuitBreakerDecorator, decorator)