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
52impl BreakerConfig {
53 #[must_use]
59 pub fn from_env() -> Self {
60 Self::from_opt(
61 std::env::var("WM_BREAKER_THRESHOLD").ok().as_deref(),
62 std::env::var("WM_BREAKER_WINDOW_MS").ok().as_deref(),
63 std::env::var("WM_BREAKER_COOLDOWN_MS").ok().as_deref(),
64 )
65 }
66
67 #[must_use]
69 fn from_opt(
70 threshold: Option<&str>,
71 window_ms: Option<&str>,
72 cooldown_ms: Option<&str>,
73 ) -> Self {
74 let default = Self::default();
75 Self {
76 failure_threshold: parse_env(
77 "WM_BREAKER_THRESHOLD",
78 threshold,
79 default.failure_threshold,
80 ),
81 window: Duration::from_millis(parse_env(
82 "WM_BREAKER_WINDOW_MS",
83 window_ms,
84 default.window.as_millis() as u64,
85 )),
86 cooldown: Duration::from_millis(parse_env(
87 "WM_BREAKER_COOLDOWN_MS",
88 cooldown_ms,
89 default.cooldown.as_millis() as u64,
90 )),
91 }
92 }
93}
94
95fn parse_env<T>(key: &str, raw: Option<&str>, default: T) -> T
98where
99 T: std::str::FromStr,
100 T::Err: std::fmt::Debug,
101{
102 match raw {
103 None => default,
104 Some(value) => match value.parse::<T>() {
105 Ok(parsed) => parsed,
106 Err(error) => {
107 tracing::warn!(
108 variable = key,
109 value = value,
110 error = ?error,
111 "circuit-breaker env value invalid — using default"
112 );
113 default
114 }
115 },
116 }
117}
118
119pub struct CircuitBreaker {
121 tool_name: String,
122 config: BreakerConfig,
123 state: BreakerState,
124 failure_timestamps: Vec<Instant>,
125 opened_at: Instant,
126 total_trips: u64,
127 probe_in_flight: bool,
131 probe_started_at: Instant,
134}
135
136impl CircuitBreaker {
137 pub fn new(tool_name: impl Into<String>, config: BreakerConfig) -> Self {
139 Self {
140 tool_name: tool_name.into(),
141 config,
142 state: BreakerState::Closed,
143 failure_timestamps: Vec::new(),
144 opened_at: Instant::now(),
145 total_trips: 0,
146 probe_in_flight: false,
147 probe_started_at: Instant::now(),
148 }
149 }
150
151 #[must_use]
153 pub fn tool_name(&self) -> &str {
154 &self.tool_name
155 }
156
157 #[must_use]
159 pub const fn state(&self) -> BreakerState {
160 self.state
161 }
162
163 #[must_use]
165 pub const fn total_trips(&self) -> u64 {
166 self.total_trips
167 }
168
169 pub fn is_open(&mut self) -> bool {
175 match self.state {
176 BreakerState::Closed => false,
177 BreakerState::Open => {
178 let elapsed = Instant::now().saturating_duration_since(self.opened_at);
179 if elapsed >= self.config.cooldown {
180 self.state = BreakerState::HalfOpen;
181 self.probe_in_flight = true;
183 self.probe_started_at = Instant::now();
184 tracing::info!(
185 tool = %self.tool_name,
186 "Circuit breaker: OPEN → HALF_OPEN (cooldown elapsed)"
187 );
188 false } else {
190 true
191 }
192 }
193 BreakerState::HalfOpen => {
194 let probe_stale = self.probe_in_flight
198 && Instant::now().saturating_duration_since(self.probe_started_at)
199 >= self.config.cooldown;
200 if self.probe_in_flight && !probe_stale {
201 true } else {
203 self.probe_in_flight = true;
204 self.probe_started_at = Instant::now();
205 false
206 }
207 }
208 }
209 }
210
211 pub fn record_success(&mut self) {
213 if self.state == BreakerState::HalfOpen {
214 self.state = BreakerState::Closed;
215 self.failure_timestamps.clear();
216 self.probe_in_flight = false;
217 tracing::info!(
218 tool = %self.tool_name,
219 "Circuit breaker: HALF_OPEN → CLOSED (probe succeeded)"
220 );
221 }
222 }
225
226 pub fn record_failure(&mut self) {
228 let now = Instant::now();
229
230 if self.state == BreakerState::HalfOpen {
231 self.state = BreakerState::Open;
234 self.opened_at = now;
235 self.probe_in_flight = false;
236 self.total_trips += 1;
237 tracing::warn!(
238 tool = %self.tool_name,
239 trip_count = self.total_trips,
240 "Circuit breaker: HALF_OPEN → OPEN (probe failed)"
241 );
242 return;
243 }
244
245 if let Some(cutoff) = now.checked_sub(self.config.window) {
248 self.failure_timestamps.retain(|t| *t >= cutoff);
249 }
250 self.failure_timestamps.push(now);
251
252 if self.failure_timestamps.len() >= self.config.failure_threshold as usize {
253 self.state = BreakerState::Open;
254 self.opened_at = now;
255 self.probe_in_flight = false;
256 self.total_trips += 1;
257 tracing::warn!(
258 tool = %self.tool_name,
259 failures = self.failure_timestamps.len(),
260 window_secs = self.config.window.as_secs(),
261 trip_count = self.total_trips,
262 "Circuit breaker: CLOSED → OPEN"
263 );
264 }
265 }
266
267 pub fn reset(&mut self) {
269 self.state = BreakerState::Closed;
270 self.failure_timestamps.clear();
271 self.probe_in_flight = false;
272 self.total_trips = 0;
273 }
274
275 #[must_use]
277 pub fn remaining_cooldown(&self) -> Duration {
278 if self.state == BreakerState::Open {
279 let elapsed = Instant::now().saturating_duration_since(self.opened_at);
280 self.config.cooldown.saturating_sub(elapsed)
281 } else {
282 Duration::ZERO
283 }
284 }
285}
286
287pub struct CircuitBreakerRegistry {
289 breakers: RwLock<HashMap<String, CircuitBreaker>>,
290 default_config: BreakerConfig,
291}
292
293impl CircuitBreakerRegistry {
294 #[must_use]
296 pub fn new(default_config: BreakerConfig) -> Self {
297 Self {
298 breakers: RwLock::new(HashMap::new()),
299 default_config,
300 }
301 }
302
303 pub fn is_open(&self, tool_name: &str) -> bool {
307 if let Ok(mut guard) = self.breakers.write() {
308 let breaker = guard
309 .entry(tool_name.to_string())
310 .or_insert_with(|| CircuitBreaker::new(tool_name, self.default_config.clone()));
311 breaker.is_open()
312 } else {
313 false }
315 }
316
317 pub fn record_success(&self, tool_name: &str) {
319 if let Ok(mut guard) = self.breakers.write() {
320 if let Some(breaker) = guard.get_mut(tool_name) {
321 breaker.record_success();
322 }
323 }
324 }
325
326 pub fn record_failure(&self, tool_name: &str) {
328 if let Ok(mut guard) = self.breakers.write() {
329 let breaker = guard
330 .entry(tool_name.to_string())
331 .or_insert_with(|| CircuitBreaker::new(tool_name, self.default_config.clone()));
332 breaker.record_failure();
333 }
334 }
335
336 pub fn state(&self, tool_name: &str) -> BreakerState {
338 if let Ok(guard) = self.breakers.read() {
339 guard
340 .get(tool_name)
341 .map_or(BreakerState::Closed, CircuitBreaker::state)
342 } else {
343 BreakerState::Closed
344 }
345 }
346
347 pub fn reset(&self, tool_name: &str) {
349 if let Ok(mut guard) = self.breakers.write() {
350 if let Some(breaker) = guard.get_mut(tool_name) {
351 breaker.reset();
352 }
353 }
354 }
355
356 pub fn reset_all(&self) -> usize {
359 if let Ok(mut guard) = self.breakers.write() {
360 let count = guard.len();
361 for breaker in guard.values_mut() {
362 breaker.reset();
363 }
364 count
365 } else {
366 0
367 }
368 }
369
370 pub fn total_trips(&self, tool_name: &str) -> u64 {
372 if let Ok(guard) = self.breakers.read() {
373 guard.get(tool_name).map_or(0, CircuitBreaker::total_trips)
374 } else {
375 0
376 }
377 }
378
379 #[must_use]
382 pub fn from_env() -> Self {
383 Self::new(BreakerConfig::from_env())
384 }
385
386 #[must_use]
390 pub fn snapshot(&self) -> serde_json::Value {
391 let Ok(guard) = self.breakers.read() else {
392 return serde_json::json!({"error": "breaker registry lock poisoned"});
393 };
394 let mut open = Vec::new();
395 let mut half_open = Vec::new();
396 let mut trips = serde_json::Map::new();
397 for (name, breaker) in guard.iter() {
398 match breaker.state() {
399 BreakerState::Open => open.push(name.clone()),
400 BreakerState::HalfOpen => half_open.push(name.clone()),
401 BreakerState::Closed => {}
402 }
403 if breaker.total_trips() > 0 {
404 trips.insert(name.clone(), serde_json::json!(breaker.total_trips()));
405 }
406 }
407 open.sort();
408 half_open.sort();
409 serde_json::json!({
410 "open": open,
411 "half_open": half_open,
412 "trips": trips,
413 })
414 }
415}
416
417impl Default for CircuitBreakerRegistry {
418 fn default() -> Self {
419 Self::new(BreakerConfig::default())
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use std::thread;
427
428 #[test]
429 fn breaker_starts_closed() {
430 let mut b = CircuitBreaker::new("test_tool", BreakerConfig::default());
431 assert_eq!(b.state(), BreakerState::Closed);
432 assert!(!b.is_open());
433 }
434
435 #[test]
436 fn breaker_opens_after_threshold() {
437 let config = BreakerConfig {
438 failure_threshold: 3,
439 window: Duration::from_secs(10),
440 cooldown: Duration::from_secs(30),
441 };
442 let mut b = CircuitBreaker::new("test_tool", config);
443
444 b.record_failure();
445 b.record_failure();
446 assert_eq!(b.state(), BreakerState::Closed);
447
448 b.record_failure();
449 assert_eq!(b.state(), BreakerState::Open);
450 assert_eq!(b.total_trips(), 1);
451 assert!(b.is_open());
452 }
453
454 #[test]
455 fn breaker_half_open_after_cooldown() {
456 let config = BreakerConfig {
457 failure_threshold: 1,
458 window: Duration::from_secs(10),
459 cooldown: Duration::from_millis(50),
460 };
461 let mut b = CircuitBreaker::new("test_tool", config);
462
463 b.record_failure();
464 assert_eq!(b.state(), BreakerState::Open);
465
466 thread::sleep(Duration::from_millis(60));
468 assert!(!b.is_open()); assert_eq!(b.state(), BreakerState::HalfOpen);
470 }
471
472 #[test]
473 fn half_open_success_closes() {
474 let config = BreakerConfig {
475 failure_threshold: 1,
476 window: Duration::from_secs(10),
477 cooldown: Duration::from_millis(50),
478 };
479 let mut b = CircuitBreaker::new("test_tool", config);
480
481 b.record_failure();
482 thread::sleep(Duration::from_millis(60));
483 b.is_open(); b.record_success();
485 assert_eq!(b.state(), BreakerState::Closed);
486 }
487
488 #[test]
489 fn half_open_failure_reopens() {
490 let config = BreakerConfig {
491 failure_threshold: 1,
492 window: Duration::from_secs(10),
493 cooldown: Duration::from_millis(50),
494 };
495 let mut b = CircuitBreaker::new("test_tool", config);
496
497 b.record_failure();
498 thread::sleep(Duration::from_millis(60));
499 b.is_open(); b.record_failure();
501 assert_eq!(b.state(), BreakerState::Open);
502 }
503
504 #[test]
505 fn failures_expire_outside_window() {
506 let config = BreakerConfig {
507 failure_threshold: 3,
508 window: Duration::from_millis(50),
509 cooldown: Duration::from_secs(30),
510 };
511 let mut b = CircuitBreaker::new("test_tool", config);
512
513 b.record_failure();
514 b.record_failure();
515 thread::sleep(Duration::from_millis(60));
516 b.record_failure();
517 assert_eq!(b.state(), BreakerState::Closed);
519 }
520
521 #[test]
522 fn registry_tracks_per_tool() {
523 let registry = CircuitBreakerRegistry::new(BreakerConfig {
524 failure_threshold: 2,
525 window: Duration::from_secs(10),
526 cooldown: Duration::from_secs(30),
527 });
528
529 registry.record_failure("tool_a");
531 registry.record_failure("tool_a");
532 assert_eq!(registry.state("tool_a"), BreakerState::Open);
533 assert!(registry.is_open("tool_a"));
534
535 assert_eq!(registry.state("tool_b"), BreakerState::Closed);
537 assert!(!registry.is_open("tool_b"));
538 }
539
540 #[test]
541 fn registry_reset() {
542 let registry = CircuitBreakerRegistry::new(BreakerConfig {
543 failure_threshold: 1,
544 window: Duration::from_secs(10),
545 cooldown: Duration::from_secs(30),
546 });
547
548 registry.record_failure("tool_x");
549 assert_eq!(registry.state("tool_x"), BreakerState::Open);
550 registry.reset("tool_x");
551 assert_eq!(registry.state("tool_x"), BreakerState::Closed);
552 }
553
554 #[test]
555 fn remaining_cooldown_decreases() {
556 let config = BreakerConfig {
557 failure_threshold: 1,
558 window: Duration::from_secs(10),
559 cooldown: Duration::from_millis(100),
560 };
561 let mut b = CircuitBreaker::new("test_tool", config);
562
563 b.record_failure();
564 let remaining = b.remaining_cooldown();
565 assert!(remaining > Duration::ZERO);
566 assert!(remaining <= Duration::from_millis(100));
567
568 thread::sleep(Duration::from_millis(60));
569 let remaining2 = b.remaining_cooldown();
570 assert!(remaining2 < remaining);
571 }
572
573 #[test]
574 fn large_window_doesnt_panic() {
575 let config = BreakerConfig {
578 failure_threshold: 1,
579 window: Duration::from_secs(u64::MAX / 1_000_000_000),
580 cooldown: Duration::from_secs(30),
581 };
582 let mut b = CircuitBreaker::new("test_tool", config);
583
584 b.record_failure();
586 assert_eq!(b.state(), BreakerState::Open);
587 }
588
589 #[test]
590 fn half_open_admits_single_probe() {
591 let config = BreakerConfig {
592 failure_threshold: 1,
593 window: Duration::from_secs(10),
594 cooldown: Duration::from_millis(20),
595 };
596 let mut b = CircuitBreaker::new("test_tool", config);
597 b.record_failure();
598 assert!(b.is_open());
599
600 thread::sleep(Duration::from_millis(30));
601 assert!(!b.is_open(), "first caller after cooldown is the probe");
602 assert!(
603 b.is_open(),
604 "concurrent callers must fast-fail while the probe is out"
605 );
606
607 b.record_success();
608 assert_eq!(b.state(), BreakerState::Closed);
609 assert!(!b.is_open());
610 }
611
612 #[test]
613 fn half_open_failure_counts_new_trip() {
614 let config = BreakerConfig {
615 failure_threshold: 1,
616 window: Duration::from_secs(10),
617 cooldown: Duration::from_millis(20),
618 };
619 let mut b = CircuitBreaker::new("test_tool", config);
620 b.record_failure();
621 assert_eq!(b.total_trips(), 1);
622
623 thread::sleep(Duration::from_millis(30));
624 assert!(!b.is_open()); b.record_failure(); assert_eq!(b.state(), BreakerState::Open);
628 assert_eq!(b.total_trips(), 2, "half-open re-open is a fresh trip");
629 }
630
631 #[test]
632 fn stale_probe_does_not_wedge_half_open() {
633 let config = BreakerConfig {
634 failure_threshold: 1,
635 window: Duration::from_secs(10),
636 cooldown: Duration::from_millis(20),
637 };
638 let mut b = CircuitBreaker::new("test_tool", config);
639 b.record_failure();
640
641 thread::sleep(Duration::from_millis(30));
642 assert!(!b.is_open()); thread::sleep(Duration::from_millis(30));
645 assert!(
646 !b.is_open(),
647 "a dead probe older than cooldown must be replaceable, not wedged"
648 );
649 assert_eq!(b.state(), BreakerState::HalfOpen);
650 }
651
652 #[test]
653 fn config_from_opt_parses_and_defaults_invalid_values() {
654 let parsed = BreakerConfig::from_opt(Some("3"), Some("2500"), Some("100"));
655 assert_eq!(parsed.failure_threshold, 3);
656 assert_eq!(parsed.window, Duration::from_millis(2500));
657 assert_eq!(parsed.cooldown, Duration::from_millis(100));
658
659 let defaults = BreakerConfig::from_opt(None, Some("not-a-number"), None);
660 assert_eq!(defaults.failure_threshold, 5);
661 assert_eq!(defaults.window, Duration::from_secs(10));
662 assert_eq!(defaults.cooldown, Duration::from_secs(30));
663 }
664
665 #[test]
666 fn snapshot_reports_open_and_trips() {
667 let registry = CircuitBreakerRegistry::new(BreakerConfig {
668 failure_threshold: 1,
669 window: Duration::from_secs(10),
670 cooldown: Duration::from_secs(30),
671 });
672 registry.record_failure("tool_a");
673 let snap = registry.snapshot();
674 assert_eq!(snap["open"], serde_json::json!(["tool_a"]));
675 assert_eq!(snap["half_open"], serde_json::json!([]));
676 assert_eq!(snap["trips"]["tool_a"], 1);
677 assert_eq!(
678 registry.snapshot()["open"].as_array().map(Vec::len),
679 Some(1)
680 );
681 }
682}