1use std::collections::HashMap;
17use std::sync::RwLock;
18use std::time::{Duration, Instant};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum BreakerState {
23 Closed,
25 Open,
27 HalfOpen,
29}
30
31#[derive(Debug, Clone)]
33pub struct BreakerConfig {
34 pub failure_threshold: u32,
36 pub window: Duration,
38 pub cooldown: Duration,
40}
41
42impl Default for BreakerConfig {
43 fn default() -> Self {
44 Self {
45 failure_threshold: 5,
46 window: Duration::from_secs(10),
47 cooldown: Duration::from_secs(30),
48 }
49 }
50}
51
52pub struct CircuitBreaker {
54 tool_name: String,
55 config: BreakerConfig,
56 state: BreakerState,
57 failure_timestamps: Vec<Instant>,
58 opened_at: Instant,
59 total_trips: u64,
60}
61
62impl CircuitBreaker {
63 pub fn new(tool_name: impl Into<String>, config: BreakerConfig) -> Self {
65 Self {
66 tool_name: tool_name.into(),
67 config,
68 state: BreakerState::Closed,
69 failure_timestamps: Vec::new(),
70 opened_at: Instant::now(),
71 total_trips: 0,
72 }
73 }
74
75 #[must_use]
77 pub fn tool_name(&self) -> &str {
78 &self.tool_name
79 }
80
81 #[must_use]
83 pub const fn state(&self) -> BreakerState {
84 self.state
85 }
86
87 #[must_use]
89 pub const fn total_trips(&self) -> u64 {
90 self.total_trips
91 }
92
93 pub fn is_open(&mut self) -> bool {
99 match self.state {
100 BreakerState::Closed => false,
101 BreakerState::Open => {
102 let elapsed = Instant::now().saturating_duration_since(self.opened_at);
103 if elapsed >= self.config.cooldown {
104 self.state = BreakerState::HalfOpen;
105 tracing::info!(
106 tool = %self.tool_name,
107 "Circuit breaker: OPEN → HALF_OPEN (cooldown elapsed)"
108 );
109 false } else {
111 true
112 }
113 }
114 BreakerState::HalfOpen => false, }
116 }
117
118 pub fn record_success(&mut self) {
120 if self.state == BreakerState::HalfOpen {
121 self.state = BreakerState::Closed;
122 self.failure_timestamps.clear();
123 tracing::info!(
124 tool = %self.tool_name,
125 "Circuit breaker: HALF_OPEN → CLOSED (probe succeeded)"
126 );
127 }
128 }
131
132 pub fn record_failure(&mut self) {
134 let now = Instant::now();
135
136 if self.state == BreakerState::HalfOpen {
137 self.state = BreakerState::Open;
139 self.opened_at = now;
140 tracing::warn!(
141 tool = %self.tool_name,
142 "Circuit breaker: HALF_OPEN → OPEN (probe failed)"
143 );
144 return;
145 }
146
147 if let Some(cutoff) = now.checked_sub(self.config.window) {
150 self.failure_timestamps.retain(|t| *t >= cutoff);
151 }
152 self.failure_timestamps.push(now);
153
154 if self.failure_timestamps.len() >= self.config.failure_threshold as usize {
155 self.state = BreakerState::Open;
156 self.opened_at = now;
157 self.total_trips += 1;
158 tracing::warn!(
159 tool = %self.tool_name,
160 failures = self.failure_timestamps.len(),
161 window_secs = self.config.window.as_secs(),
162 trip_count = self.total_trips,
163 "Circuit breaker: CLOSED → OPEN"
164 );
165 }
166 }
167
168 pub fn reset(&mut self) {
170 self.state = BreakerState::Closed;
171 self.failure_timestamps.clear();
172 self.total_trips = 0;
173 }
174
175 #[must_use]
177 pub fn remaining_cooldown(&self) -> Duration {
178 if self.state == BreakerState::Open {
179 let elapsed = Instant::now().saturating_duration_since(self.opened_at);
180 self.config.cooldown.saturating_sub(elapsed)
181 } else {
182 Duration::ZERO
183 }
184 }
185}
186
187pub struct CircuitBreakerRegistry {
189 breakers: RwLock<HashMap<String, CircuitBreaker>>,
190 default_config: BreakerConfig,
191}
192
193impl CircuitBreakerRegistry {
194 #[must_use]
196 pub fn new(default_config: BreakerConfig) -> Self {
197 Self {
198 breakers: RwLock::new(HashMap::new()),
199 default_config,
200 }
201 }
202
203 pub fn is_open(&self, tool_name: &str) -> bool {
207 if let Ok(mut guard) = self.breakers.write() {
208 let breaker = guard
209 .entry(tool_name.to_string())
210 .or_insert_with(|| CircuitBreaker::new(tool_name, self.default_config.clone()));
211 breaker.is_open()
212 } else {
213 false }
215 }
216
217 pub fn record_success(&self, tool_name: &str) {
219 if let Ok(mut guard) = self.breakers.write() {
220 if let Some(breaker) = guard.get_mut(tool_name) {
221 breaker.record_success();
222 }
223 }
224 }
225
226 pub fn record_failure(&self, tool_name: &str) {
228 if let Ok(mut guard) = self.breakers.write() {
229 let breaker = guard
230 .entry(tool_name.to_string())
231 .or_insert_with(|| CircuitBreaker::new(tool_name, self.default_config.clone()));
232 breaker.record_failure();
233 }
234 }
235
236 pub fn state(&self, tool_name: &str) -> BreakerState {
238 if let Ok(guard) = self.breakers.read() {
239 guard
240 .get(tool_name)
241 .map_or(BreakerState::Closed, CircuitBreaker::state)
242 } else {
243 BreakerState::Closed
244 }
245 }
246
247 pub fn reset(&self, tool_name: &str) {
249 if let Ok(mut guard) = self.breakers.write() {
250 if let Some(breaker) = guard.get_mut(tool_name) {
251 breaker.reset();
252 }
253 }
254 }
255
256 pub fn total_trips(&self, tool_name: &str) -> u64 {
258 if let Ok(guard) = self.breakers.read() {
259 guard.get(tool_name).map_or(0, CircuitBreaker::total_trips)
260 } else {
261 0
262 }
263 }
264}
265
266impl Default for CircuitBreakerRegistry {
267 fn default() -> Self {
268 Self::new(BreakerConfig::default())
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use std::thread;
276
277 #[test]
278 fn breaker_starts_closed() {
279 let mut b = CircuitBreaker::new("test_tool", BreakerConfig::default());
280 assert_eq!(b.state(), BreakerState::Closed);
281 assert!(!b.is_open());
282 }
283
284 #[test]
285 fn breaker_opens_after_threshold() {
286 let config = BreakerConfig {
287 failure_threshold: 3,
288 window: Duration::from_secs(10),
289 cooldown: Duration::from_secs(30),
290 };
291 let mut b = CircuitBreaker::new("test_tool", config);
292
293 b.record_failure();
294 b.record_failure();
295 assert_eq!(b.state(), BreakerState::Closed);
296
297 b.record_failure();
298 assert_eq!(b.state(), BreakerState::Open);
299 assert_eq!(b.total_trips(), 1);
300 assert!(b.is_open());
301 }
302
303 #[test]
304 fn breaker_half_open_after_cooldown() {
305 let config = BreakerConfig {
306 failure_threshold: 1,
307 window: Duration::from_secs(10),
308 cooldown: Duration::from_millis(50),
309 };
310 let mut b = CircuitBreaker::new("test_tool", config);
311
312 b.record_failure();
313 assert_eq!(b.state(), BreakerState::Open);
314
315 thread::sleep(Duration::from_millis(60));
317 assert!(!b.is_open()); assert_eq!(b.state(), BreakerState::HalfOpen);
319 }
320
321 #[test]
322 fn half_open_success_closes() {
323 let config = BreakerConfig {
324 failure_threshold: 1,
325 window: Duration::from_secs(10),
326 cooldown: Duration::from_millis(50),
327 };
328 let mut b = CircuitBreaker::new("test_tool", config);
329
330 b.record_failure();
331 thread::sleep(Duration::from_millis(60));
332 b.is_open(); b.record_success();
334 assert_eq!(b.state(), BreakerState::Closed);
335 }
336
337 #[test]
338 fn half_open_failure_reopens() {
339 let config = BreakerConfig {
340 failure_threshold: 1,
341 window: Duration::from_secs(10),
342 cooldown: Duration::from_millis(50),
343 };
344 let mut b = CircuitBreaker::new("test_tool", config);
345
346 b.record_failure();
347 thread::sleep(Duration::from_millis(60));
348 b.is_open(); b.record_failure();
350 assert_eq!(b.state(), BreakerState::Open);
351 }
352
353 #[test]
354 fn failures_expire_outside_window() {
355 let config = BreakerConfig {
356 failure_threshold: 3,
357 window: Duration::from_millis(50),
358 cooldown: Duration::from_secs(30),
359 };
360 let mut b = CircuitBreaker::new("test_tool", config);
361
362 b.record_failure();
363 b.record_failure();
364 thread::sleep(Duration::from_millis(60));
365 b.record_failure();
366 assert_eq!(b.state(), BreakerState::Closed);
368 }
369
370 #[test]
371 fn registry_tracks_per_tool() {
372 let registry = CircuitBreakerRegistry::new(BreakerConfig {
373 failure_threshold: 2,
374 window: Duration::from_secs(10),
375 cooldown: Duration::from_secs(30),
376 });
377
378 registry.record_failure("tool_a");
380 registry.record_failure("tool_a");
381 assert_eq!(registry.state("tool_a"), BreakerState::Open);
382 assert!(registry.is_open("tool_a"));
383
384 assert_eq!(registry.state("tool_b"), BreakerState::Closed);
386 assert!(!registry.is_open("tool_b"));
387 }
388
389 #[test]
390 fn registry_reset() {
391 let registry = CircuitBreakerRegistry::new(BreakerConfig {
392 failure_threshold: 1,
393 window: Duration::from_secs(10),
394 cooldown: Duration::from_secs(30),
395 });
396
397 registry.record_failure("tool_x");
398 assert_eq!(registry.state("tool_x"), BreakerState::Open);
399 registry.reset("tool_x");
400 assert_eq!(registry.state("tool_x"), BreakerState::Closed);
401 }
402
403 #[test]
404 fn remaining_cooldown_decreases() {
405 let config = BreakerConfig {
406 failure_threshold: 1,
407 window: Duration::from_secs(10),
408 cooldown: Duration::from_millis(100),
409 };
410 let mut b = CircuitBreaker::new("test_tool", config);
411
412 b.record_failure();
413 let remaining = b.remaining_cooldown();
414 assert!(remaining > Duration::ZERO);
415 assert!(remaining <= Duration::from_millis(100));
416
417 thread::sleep(Duration::from_millis(60));
418 let remaining2 = b.remaining_cooldown();
419 assert!(remaining2 < remaining);
420 }
421
422 #[test]
423 fn large_window_doesnt_panic() {
424 let config = BreakerConfig {
427 failure_threshold: 1,
428 window: Duration::from_secs(u64::MAX / 1_000_000_000),
429 cooldown: Duration::from_secs(30),
430 };
431 let mut b = CircuitBreaker::new("test_tool", config);
432
433 b.record_failure();
435 assert_eq!(b.state(), BreakerState::Open);
436 }
437}