Coverage for qreward / utils / scheduler / circuit_breaker.py: 98.90%
73 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-23 19:32 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-23 19:32 +0000
1"""Circuit breaker implementation for fault tolerance.
3Provides automatic circuit breaking when consecutive failures reach a
4threshold, with half-open state probing for recovery.
5"""
7import logging
8import threading
9import time
10from enum import Enum
11from typing import Callable, Optional
13logger = logging.getLogger(__name__)
16class CircuitState(Enum):
17 """Circuit breaker state enumeration.
19 Attributes:
20 CLOSED: Normal state, requests are allowed.
21 OPEN: Tripped state, requests are blocked.
22 HALF_OPEN: Probing state, limited requests allowed to test recovery.
23 """
25 CLOSED = "closed"
26 OPEN = "open"
27 HALF_OPEN = "half_open"
30class CircuitBreaker:
31 """Thread-safe circuit breaker implementation.
33 When consecutive failures reach ``failure_threshold`` the breaker trips
34 to OPEN state, blocking all requests. After ``recovery_timeout`` seconds
35 it transitions to HALF_OPEN, allowing up to ``half_open_max_calls``
36 probe requests. A successful probe resets the breaker to CLOSED; a
37 failed probe re-opens it.
39 Args:
40 failure_threshold: Number of consecutive failures before opening.
41 recovery_timeout: Seconds to wait before transitioning to HALF_OPEN.
42 half_open_max_calls: Max probe requests allowed in HALF_OPEN state.
43 time_func: Clock function for testability (default ``time.time``).
44 """
46 def __init__(
47 self,
48 failure_threshold: int = 5,
49 recovery_timeout: float = 30.0,
50 half_open_max_calls: int = 1,
51 time_func: Optional[Callable[[], float]] = None,
52 ) -> None:
53 self._failure_threshold = failure_threshold
54 self._recovery_timeout = recovery_timeout
55 self._half_open_max_calls = half_open_max_calls
56 self._time_func = time_func or time.time
58 self._lock = threading.Lock()
59 self._state = CircuitState.CLOSED
60 self._failure_count = 0
61 self._last_failure_time: float = 0.0
62 self._half_open_calls = 0
64 # ------------------------------------------------------------------
65 # Public API
66 # ------------------------------------------------------------------
68 @property
69 def state(self) -> CircuitState:
70 """Return the current circuit state.
72 Automatically transitions from OPEN → HALF_OPEN when the recovery
73 timeout has elapsed.
74 """
75 with self._lock:
76 self._maybe_transition_to_half_open()
77 return self._state
79 def allow_request(self) -> bool:
80 """Check whether a request should be allowed.
82 Returns:
83 True if the request is permitted, False otherwise.
84 """
85 with self._lock:
86 self._maybe_transition_to_half_open()
88 if self._state == CircuitState.CLOSED:
89 return True
91 if self._state == CircuitState.HALF_OPEN:
92 if self._half_open_calls < self._half_open_max_calls:
93 self._half_open_calls += 1
94 return True
95 return False
97 # OPEN
98 return False
100 def record_success(self) -> None:
101 """Record a successful request and update state accordingly."""
102 with self._lock:
103 if self._state == CircuitState.HALF_OPEN:
104 logger.info(
105 "CircuitBreaker transitioning HALF_OPEN -> CLOSED "
106 "(probe succeeded)"
107 )
108 self._reset_unlocked()
109 elif self._state == CircuitState.CLOSED: 109 ↛ exitline 109 didn't jump to the function exit
110 self._failure_count = 0
112 def record_failure(self) -> None:
113 """Record a failed request and update state accordingly."""
114 with self._lock:
115 if self._state == CircuitState.HALF_OPEN:
116 logger.warning(
117 "CircuitBreaker transitioning HALF_OPEN -> OPEN "
118 "(probe failed)"
119 )
120 self._state = CircuitState.OPEN
121 self._last_failure_time = self._time_func()
122 self._half_open_calls = 0
123 return
125 self._failure_count += 1
126 if self._failure_count >= self._failure_threshold:
127 logger.warning(
128 "CircuitBreaker transitioning CLOSED -> OPEN "
129 "(failure_count=%d >= threshold=%d)",
130 self._failure_count,
131 self._failure_threshold,
132 )
133 self._state = CircuitState.OPEN
134 self._last_failure_time = self._time_func()
136 def reset(self) -> None:
137 """Reset the breaker to its initial CLOSED state."""
138 with self._lock:
139 self._reset_unlocked()
141 # ------------------------------------------------------------------
142 # Internal helpers (must be called with lock held)
143 # ------------------------------------------------------------------
145 def _reset_unlocked(self) -> None:
146 self._state = CircuitState.CLOSED
147 self._failure_count = 0
148 self._half_open_calls = 0
149 self._last_failure_time = 0.0
151 def _maybe_transition_to_half_open(self) -> None:
152 if self._state != CircuitState.OPEN:
153 return
154 elapsed = self._time_func() - self._last_failure_time
155 if elapsed >= self._recovery_timeout:
156 logger.info(
157 "CircuitBreaker transitioning OPEN -> HALF_OPEN "
158 "(recovery_timeout=%.1fs elapsed)",
159 self._recovery_timeout,
160 )
161 self._state = CircuitState.HALF_OPEN
162 self._half_open_calls = 0