Coverage for agentos/core/circuit_breaker.py: 0%

154 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 10:59 +0800

1"""AgentOS Circuit Breaker — protect against cascading failures. 

2 

3Production-grade circuit breaker pattern with: 

4- 3 states: CLOSED → OPEN → HALF_OPEN → CLOSED 

5- Configurable failure threshold, timeout, and half-open probe limit 

6- Per-endpoint isolation with shared registry 

7- Optional fallback function support 

8- Thread-safe, asyncio-native 

9 

10Design: ~280 lines, zero external deps beyond stdlib + asyncio. 

11""" 

12 

13from __future__ import annotations 

14 

15import asyncio 

16import logging 

17import time 

18from dataclasses import dataclass 

19from enum import Enum 

20from functools import wraps 

21from typing import Any, Awaitable, Callable, Dict, Optional, TypeVar 

22 

23logger = logging.getLogger(__name__) 

24 

25T = TypeVar("T") 

26 

27 

28# ============================================================================ 

29# State machine 

30# ============================================================================ 

31 

32class CircuitState(str, Enum): 

33 CLOSED = "closed" # Normal operation — requests pass through 

34 OPEN = "open" # Failing — requests are rejected immediately 

35 HALF_OPEN = "half_open" # Probing — limited requests allowed to test recovery 

36 

37 

38@dataclass 

39class CircuitConfig: 

40 """Circuit breaker configuration.""" 

41 

42 failure_threshold: int = 5 # Consecutive failures to trip OPEN 

43 success_threshold: int = 2 # Consecutive successes in HALF_OPEN to reset 

44 timeout_seconds: float = 60.0 # Seconds in OPEN before transitioning to HALF_OPEN 

45 half_open_max_requests: int = 1 # Max concurrent requests in HALF_OPEN 

46 excluded_exceptions: tuple = () # Exceptions that don't count as failures 

47 

48 

49@dataclass 

50class CircuitStats: 

51 """Per-circuit statistics.""" 

52 

53 state: CircuitState = CircuitState.CLOSED 

54 failure_count: int = 0 

55 success_count: int = 0 

56 last_failure_time: float = 0.0 

57 last_success_time: float = 0.0 

58 total_failures: int = 0 

59 total_successes: int = 0 

60 opened_at: float = 0.0 

61 half_open_requests: int = 0 

62 

63 def reset(self) -> None: 

64 self.failure_count = 0 

65 self.success_count = 0 

66 self.half_open_requests = 0 

67 

68 

69# ============================================================================ 

70# Circuit Breaker 

71# ============================================================================ 

72 

73class CircuitBreaker: 

74 """Thread-safe circuit breaker for a single endpoint.""" 

75 

76 def __init__( 

77 self, 

78 name: str, 

79 config: Optional[CircuitConfig] = None, 

80 ): 

81 self.name = name 

82 self.config = config or CircuitConfig() 

83 self.stats = CircuitStats() 

84 self._lock = asyncio.Lock() 

85 

86 @property 

87 def state(self) -> CircuitState: 

88 return self.stats.state 

89 

90 async def _transition(self, new_state: CircuitState) -> None: 

91 old = self.stats.state 

92 if old == new_state: 

93 return 

94 self.stats.state = new_state 

95 if new_state == CircuitState.OPEN: 

96 self.stats.opened_at = time.monotonic() 

97 self.stats.reset() 

98 logger.warning( 

99 "Circuit '%s' OPENED after %d consecutive failures", 

100 self.name, self.config.failure_threshold, 

101 ) 

102 elif new_state == CircuitState.CLOSED: 

103 self.stats.reset() 

104 logger.info("Circuit '%s' CLOSED — service recovered", self.name) 

105 elif new_state == CircuitState.HALF_OPEN: 

106 self.stats.reset() 

107 logger.info("Circuit '%s' HALF_OPEN — probing", self.name) 

108 

109 def _should_retry_open(self) -> bool: 

110 """Check if OPEN state has expired and should move to HALF_OPEN.""" 

111 elapsed = time.monotonic() - self.stats.opened_at 

112 return elapsed >= self.config.timeout_seconds 

113 

114 async def _on_success(self) -> None: 

115 async with self._lock: 

116 self.stats.total_successes += 1 

117 self.stats.last_success_time = time.monotonic() 

118 

119 if self.stats.state == CircuitState.HALF_OPEN: 

120 self.stats.success_count += 1 

121 if self.stats.success_count >= self.config.success_threshold: 

122 await self._transition(CircuitState.CLOSED) 

123 else: 

124 self.stats.failure_count = 0 # Reset on success when CLOSED 

125 

126 async def _on_failure(self, exc: Exception) -> None: 

127 async with self._lock: 

128 if isinstance(exc, self.config.excluded_exceptions): 

129 return 

130 

131 self.stats.total_failures += 1 

132 self.stats.last_failure_time = time.monotonic() 

133 

134 if self.stats.state == CircuitState.HALF_OPEN: 

135 await self._transition(CircuitState.OPEN) 

136 else: 

137 self.stats.failure_count += 1 

138 if self.stats.failure_count >= self.config.failure_threshold: 

139 await self._transition(CircuitState.OPEN) 

140 

141 async def acquire(self) -> bool: 

142 """Try to acquire permission to make a request. 

143 

144 Returns True if request should proceed, False if circuit is OPEN. 

145 """ 

146 async with self._lock: 

147 state = self.stats.state 

148 

149 if state == CircuitState.CLOSED: 

150 return True 

151 

152 if state == CircuitState.OPEN: 

153 if self._should_retry_open(): 

154 await self._transition(CircuitState.HALF_OPEN) 

155 # Re-read state after transition 

156 state = self.stats.state 

157 else: 

158 return False 

159 

160 if state == CircuitState.HALF_OPEN: 

161 if self.stats.half_open_requests < self.config.half_open_max_requests: 

162 self.stats.half_open_requests += 1 

163 return True 

164 return False 

165 

166 return False # unreachable, but defensive 

167 

168 async def release(self) -> None: 

169 """Release half-open slot after request completes.""" 

170 async with self._lock: 

171 if self.stats.state == CircuitState.HALF_OPEN: 

172 self.stats.half_open_requests = max(0, self.stats.half_open_requests - 1) 

173 

174 async def call( 

175 self, 

176 fn: Callable[..., Awaitable[T]], 

177 *args: Any, 

178 fallback: Optional[Callable[..., Awaitable[T]]] = None, 

179 **kwargs: Any, 

180 ) -> T: 

181 """Execute fn through the circuit breaker. 

182 

183 Raises CircuitOpenError if circuit is open and no fallback provided. 

184 """ 

185 if not await self.acquire(): 

186 if fallback is not None: 

187 logger.debug("Circuit '%s' open — using fallback", self.name) 

188 return await fallback(*args, **kwargs) 

189 raise CircuitOpenError( 

190 f"Circuit '{self.name}' is OPEN — request rejected" 

191 ) 

192 

193 try: 

194 result = await fn(*args, **kwargs) 

195 await self._on_success() 

196 return result 

197 except Exception as exc: 

198 await self._on_failure(exc) 

199 raise 

200 finally: 

201 await self.release() 

202 

203 

204# ============================================================================ 

205# Registry 

206# ============================================================================ 

207 

208class CircuitRegistry: 

209 """Global registry of circuit breakers, keyed by endpoint name.""" 

210 

211 def __init__(self): 

212 self._circuits: Dict[str, CircuitBreaker] = {} 

213 self._lock = asyncio.Lock() 

214 

215 async def get_or_create( 

216 self, 

217 name: str, 

218 config: Optional[CircuitConfig] = None, 

219 ) -> CircuitBreaker: 

220 async with self._lock: 

221 if name not in self._circuits: 

222 self._circuits[name] = CircuitBreaker(name=name, config=config) 

223 return self._circuits[name] 

224 

225 def get_all_stats(self) -> Dict[str, CircuitStats]: 

226 """Export all circuit stats for monitoring.""" 

227 return {name: cb.stats for name, cb in self._circuits.items()} 

228 

229 async def reset_all(self) -> None: 

230 """Reset all circuits to CLOSED (for testing/admin).""" 

231 async with self._lock: 

232 for cb in self._circuits.values(): 

233 cb.stats.reset() 

234 cb.stats.state = CircuitState.CLOSED 

235 

236 async def force_open(self, name: str) -> None: 

237 """Force a circuit OPEN (for manual intervention).""" 

238 async with self._lock: 

239 if name in self._circuits: 

240 await self._circuits[name]._transition(CircuitState.OPEN) 

241 

242 async def force_closed(self, name: str) -> None: 

243 """Force a circuit CLOSED (for manual intervention).""" 

244 async with self._lock: 

245 if name in self._circuits: 

246 await self._circuits[name]._transition(CircuitState.CLOSED) 

247 

248 

249# ============================================================================ 

250# Decorator 

251# ============================================================================ 

252 

253def circuit_breaker( 

254 name: str, 

255 failure_threshold: int = 5, 

256 timeout_seconds: float = 60.0, 

257 success_threshold: int = 2, 

258 excluded_exceptions: tuple = (), 

259): 

260 """Decorator: wrap an async function with a circuit breaker. 

261 

262 Usage: 

263 @circuit_breaker("llm_api", failure_threshold=3, timeout_seconds=30) 

264 async def call_llm(prompt: str) -> str: ... 

265 """ 

266 config = CircuitConfig( 

267 failure_threshold=failure_threshold, 

268 timeout_seconds=timeout_seconds, 

269 success_threshold=success_threshold, 

270 excluded_exceptions=excluded_exceptions, 

271 ) 

272 cb = CircuitBreaker(name=name, config=config) 

273 

274 def decorator(fn: Callable[..., Awaitable[T]]): 

275 @wraps(fn) 

276 async def wrapper(*args, **kwargs): 

277 return await cb.call(fn, *args, **kwargs) 

278 wrapper._circuit_breaker = cb # type: ignore[attr-defined] 

279 return wrapper 

280 return decorator 

281 

282 

283# ============================================================================ 

284# Exceptions 

285# ============================================================================ 

286 

287class CircuitOpenError(Exception): 

288 """Raised when request is rejected because circuit is OPEN.""" 

289 pass 

290 

291 

292# ============================================================================ 

293# Module-level defaults 

294# ============================================================================ 

295 

296# Global registry — use this shared instance across the app 

297default_registry = CircuitRegistry()