Coverage for src/taipanstack/resilience/adaptive/adaptive_breaker.py: 100%

123 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-10-13 20:52 +0000

1""" 

2Adaptive Circuit Breaker — auto-tunes failure threshold via rolling window. 

3 

4Unlike standard Circuit Breakers that use static absolute failure counts, 

5the AdaptiveCircuitBreaker opens its circuit ONLY when the error rate 

6exceeds a target percentage in a rolling window of recent calls AND a 

7minimum throughput of requests has been met. 

8""" 

9 

10from __future__ import annotations 

11 

12import logging 

13import math 

14import threading 

15import time 

16from collections import deque 

17from dataclasses import dataclass 

18from typing import TypeVar 

19 

20from taipanstack.resilience.circuit_breaker import CircuitState 

21 

22logger = logging.getLogger("taipanstack.resilience.adaptive.breaker") 

23 

24T = TypeVar("T") 

25 

26 

27def _build_adaptive_metrics( 

28 window: deque[bool], state: CircuitState 

29) -> AdaptiveMetrics: 

30 total = len(window) 

31 errors = sum(1 for ok in window if not ok) 

32 error_rate = errors / total if total > 0 else 0.0 

33 return AdaptiveMetrics( 

34 success_rate=1.0 - error_rate, 

35 error_rate=error_rate, 

36 total_calls=total, 

37 error_count=errors, 

38 state=state, 

39 ) 

40 

41 

42@dataclass(frozen=True) 

43class AdaptiveMetrics: 

44 """Snapshot of adaptive circuit breaker metrics. 

45 

46 Attributes: 

47 success_rate: Current success rate (0.0 - 1.0). 

48 error_rate: Current error rate (0.0 - 1.0). 

49 total_calls: Total calls in the window. 

50 error_count: Errors in the window. 

51 state: Current circuit state. 

52 

53 """ 

54 

55 success_rate: float 

56 error_rate: float 

57 total_calls: int 

58 error_count: int 

59 state: CircuitState 

60 

61 

62class AdaptiveCircuitBreaker: 

63 """Circuit breaker that opens based on an error rate percentage. 

64 

65 Maintains a rolling window of call outcomes. The circuit trips to OPEN if: 

66 1. The `window_size` history has at least `min_throughput` events. 

67 2. The error rate (errors / total) > `target_error_rate`. 

68 

69 Once OPEN, it waits `recovery_timeout` seconds before transitioning 

70 to HALF_OPEN. In HALF_OPEN, if a request succeeds, it CLOSES and 

71 clears the window. If it fails, it returns to OPEN. 

72 

73 Args: 

74 name: Identifier for logging. 

75 window_size: Number of recent calls to track. 

76 min_throughput: Minimum requests before considering error rate. 

77 target_error_rate: Desired error rate boundary (0.0 - 1.0). 

78 recovery_timeout: Seconds before attempting half-open recovery. 

79 

80 """ 

81 

82 def __init__( 

83 self, 

84 name: str = "default", 

85 *, 

86 window_size: int = 100, 

87 min_throughput: int = 10, 

88 target_error_rate: float = 0.5, 

89 recovery_timeout: float = 30.0, 

90 ) -> None: 

91 """Initialize the adaptive circuit breaker.""" 

92 self.name = name 

93 self._min_throughput = self._validate_min_throughput(min_throughput) 

94 self._target_error_rate = target_error_rate 

95 self._recovery_timeout = self._validate_recovery_timeout(recovery_timeout) 

96 

97 # Rolling window: True = success, False = failure 

98 self._window: deque[bool] = deque(maxlen=window_size) 

99 self._state = CircuitState.CLOSED 

100 self._last_opened_at: float = 0.0 

101 self._lock = threading.Lock() 

102 

103 @staticmethod 

104 def _validate_min_throughput(min_throughput: int) -> int: 

105 if min_throughput < 1: 

106 raise ValueError("min_throughput must be at least 1") 

107 return min_throughput 

108 

109 @staticmethod 

110 def _validate_recovery_timeout(recovery_timeout: float) -> float: 

111 if not isinstance(recovery_timeout, (int, float)): 

112 raise TypeError("recovery_timeout must be a finite non-negative number") 

113 if not math.isfinite(recovery_timeout): 

114 raise ValueError("recovery_timeout must be a finite non-negative number") 

115 if recovery_timeout < 0: 

116 raise ValueError("recovery_timeout must be a finite non-negative number") 

117 return float(recovery_timeout) 

118 

119 def _calculate_elapsed_time(self, now: float) -> float: 

120 """Calculate elapsed time since the circuit opened.""" 

121 last_opened = self._last_opened_at 

122 if not isinstance(last_opened, (int, float)) or not math.isfinite(last_opened): 

123 return self._recovery_timeout 

124 

125 elapsed = now - last_opened 

126 return self._recovery_timeout if elapsed < 0 else elapsed 

127 

128 def _check_half_open_transition(self) -> None: 

129 """Evaluate if an OPEN circuit should transition to HALF_OPEN.""" 

130 now = time.monotonic() 

131 elapsed = self._calculate_elapsed_time(now) 

132 

133 if math.isfinite(now) and elapsed >= self._recovery_timeout: 

134 self._state = CircuitState.HALF_OPEN 

135 logger.info("Adaptive breaker '%s' entering HALF_OPEN state", self.name) 

136 

137 @property 

138 def state(self) -> CircuitState: 

139 """Current circuit state. May evaluate timeouts and switch to HALF_OPEN.""" 

140 with self._lock: 

141 if self._state is CircuitState.OPEN: 

142 self._check_half_open_transition() 

143 return self._state 

144 

145 def _calculate_error_rate(self, total: int) -> float: 

146 """Calculate the current error rate in the window.""" 

147 if total <= 0: 

148 return 0.0 

149 errors = sum(1 for ok in self._window if not ok) 

150 return errors / total 

151 

152 def _get_safe_target_rate(self) -> float: 

153 """Safely retrieve the target error rate.""" 

154 target_rate = self._target_error_rate 

155 if not isinstance(target_rate, (int, float)) or not math.isfinite(target_rate): 

156 return -1.0 # Fail closed 

157 return float(target_rate) 

158 

159 def _transition_to_open(self, error_rate: float, target_rate: float) -> None: 

160 """Transition the circuit to the OPEN state.""" 

161 self._state = CircuitState.OPEN 

162 self._last_opened_at = time.monotonic() 

163 

164 log_target = target_rate if target_rate >= 0 else 0.0 

165 logger.warning( 

166 "Adaptive breaker '%s' OPENED. Error rate %.2f > %.2f", 

167 self.name, 

168 error_rate, 

169 log_target, 

170 ) 

171 

172 def _get_safe_min_throughput(self) -> int: 

173 """Safely retrieve the minimum throughput.""" 

174 min_throughput = self._min_throughput 

175 if not isinstance(min_throughput, (int, float)) or not math.isfinite( 

176 min_throughput 

177 ): 

178 return 1 

179 return int(min_throughput) 

180 

181 def _evaluate_trip(self) -> None: 

182 """Evaluate if the circuit should trip open. 

183 

184 MUST BE CALLED UNDER LOCK. 

185 """ 

186 if self._state is not CircuitState.CLOSED: 

187 return 

188 

189 total = len(self._window) 

190 min_throughput = self._get_safe_min_throughput() 

191 

192 if total < min_throughput: 

193 return 

194 

195 error_rate = self._calculate_error_rate(total) 

196 target_rate = self._get_safe_target_rate() 

197 

198 if error_rate > target_rate: 

199 self._transition_to_open(error_rate, target_rate) 

200 

201 def record_success(self) -> None: 

202 """Record a successful call.""" 

203 with self._lock: 

204 if self._state is CircuitState.HALF_OPEN: 

205 # Full recovery on success 

206 self._state = CircuitState.CLOSED 

207 self._window.clear() 

208 logger.info( 

209 "Adaptive breaker '%s' CLOSED after successful half-open recovery.", 

210 self.name, 

211 ) 

212 

213 self._window.append(True) 

214 self._evaluate_trip() 

215 

216 def record_failure(self, _exc: Exception) -> None: 

217 """Record a failed call. 

218 

219 Args: 

220 _exc: The exception that occurred. 

221 

222 """ 

223 with self._lock: 

224 if self._state is CircuitState.HALF_OPEN: 

225 # Return to open immediately on failure 

226 self._state = CircuitState.OPEN 

227 self._last_opened_at = time.monotonic() 

228 logger.warning( 

229 "Adaptive breaker '%s' RETURNED to OPEN after half-open failure.", 

230 self.name, 

231 ) 

232 

233 self._window.append(False) 

234 self._evaluate_trip() 

235 

236 def should_allow(self) -> bool: 

237 """Check if a call should be attempted. 

238 

239 Returns: 

240 ``True`` if the circuit permits a call. 

241 

242 """ 

243 return self.state in (CircuitState.CLOSED, CircuitState.HALF_OPEN) 

244 

245 def reset(self) -> None: 

246 """Reset the breaker and window.""" 

247 with self._lock: 

248 self._window.clear() 

249 self._state = CircuitState.CLOSED 

250 self._last_opened_at = 0.0 

251 

252 @property 

253 def metrics(self) -> AdaptiveMetrics: 

254 """Snapshot of current adaptive metrics.""" 

255 with self._lock: 

256 return _build_adaptive_metrics(self._window, self._state)