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 remaining_cooldown(&self, tool_name: &str) -> std::time::Duration {
351 if let Ok(mut guard) = self.breakers.write() {
352 let breaker = guard
353 .entry(tool_name.to_string())
354 .or_insert_with(|| CircuitBreaker::new(tool_name, self.default_config.clone()));
355 breaker.remaining_cooldown()
356 } else {
357 std::time::Duration::ZERO
358 }
359 }
360
361 pub fn reset(&self, tool_name: &str) {
363 if let Ok(mut guard) = self.breakers.write() {
364 if let Some(breaker) = guard.get_mut(tool_name) {
365 breaker.reset();
366 }
367 }
368 }
369
370 pub fn reset_all(&self) -> usize {
373 if let Ok(mut guard) = self.breakers.write() {
374 let count = guard.len();
375 for breaker in guard.values_mut() {
376 breaker.reset();
377 }
378 count
379 } else {
380 0
381 }
382 }
383
384 pub fn total_trips(&self, tool_name: &str) -> u64 {
386 if let Ok(guard) = self.breakers.read() {
387 guard.get(tool_name).map_or(0, CircuitBreaker::total_trips)
388 } else {
389 0
390 }
391 }
392
393 #[must_use]
396 pub fn from_env() -> Self {
397 Self::new(BreakerConfig::from_env())
398 }
399
400 #[must_use]
404 pub fn snapshot(&self) -> serde_json::Value {
405 let Ok(guard) = self.breakers.read() else {
406 return serde_json::json!({"error": "breaker registry lock poisoned"});
407 };
408 let mut open = Vec::new();
409 let mut half_open = Vec::new();
410 let mut trips = serde_json::Map::new();
411 for (name, breaker) in guard.iter() {
412 match breaker.state() {
413 BreakerState::Open => open.push(name.clone()),
414 BreakerState::HalfOpen => half_open.push(name.clone()),
415 BreakerState::Closed => {}
416 }
417 if breaker.total_trips() > 0 {
418 trips.insert(name.clone(), serde_json::json!(breaker.total_trips()));
419 }
420 }
421 open.sort();
422 half_open.sort();
423 serde_json::json!({
424 "open": open,
425 "half_open": half_open,
426 "trips": trips,
427 })
428 }
429}
430
431impl Default for CircuitBreakerRegistry {
432 fn default() -> Self {
433 Self::new(BreakerConfig::default())
434 }
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use std::thread;
441
442 #[test]
443 fn breaker_starts_closed() {
444 let mut b = CircuitBreaker::new("test_tool", BreakerConfig::default());
445 assert_eq!(b.state(), BreakerState::Closed);
446 assert!(!b.is_open());
447 }
448
449 #[test]
450 fn breaker_opens_after_threshold() {
451 let config = BreakerConfig {
452 failure_threshold: 3,
453 window: Duration::from_secs(10),
454 cooldown: Duration::from_secs(30),
455 };
456 let mut b = CircuitBreaker::new("test_tool", config);
457
458 b.record_failure();
459 b.record_failure();
460 assert_eq!(b.state(), BreakerState::Closed);
461
462 b.record_failure();
463 assert_eq!(b.state(), BreakerState::Open);
464 assert_eq!(b.total_trips(), 1);
465 assert!(b.is_open());
466 }
467
468 #[test]
469 fn breaker_half_open_after_cooldown() {
470 let config = BreakerConfig {
471 failure_threshold: 1,
472 window: Duration::from_secs(10),
473 cooldown: Duration::from_millis(50),
474 };
475 let mut b = CircuitBreaker::new("test_tool", config);
476
477 b.record_failure();
478 assert_eq!(b.state(), BreakerState::Open);
479
480 thread::sleep(Duration::from_millis(60));
482 assert!(!b.is_open()); assert_eq!(b.state(), BreakerState::HalfOpen);
484 }
485
486 #[test]
487 fn half_open_success_closes() {
488 let config = BreakerConfig {
489 failure_threshold: 1,
490 window: Duration::from_secs(10),
491 cooldown: Duration::from_millis(50),
492 };
493 let mut b = CircuitBreaker::new("test_tool", config);
494
495 b.record_failure();
496 thread::sleep(Duration::from_millis(60));
497 b.is_open(); b.record_success();
499 assert_eq!(b.state(), BreakerState::Closed);
500 }
501
502 #[test]
503 fn half_open_failure_reopens() {
504 let config = BreakerConfig {
505 failure_threshold: 1,
506 window: Duration::from_secs(10),
507 cooldown: Duration::from_millis(50),
508 };
509 let mut b = CircuitBreaker::new("test_tool", config);
510
511 b.record_failure();
512 thread::sleep(Duration::from_millis(60));
513 b.is_open(); b.record_failure();
515 assert_eq!(b.state(), BreakerState::Open);
516 }
517
518 #[test]
519 fn failures_expire_outside_window() {
520 let config = BreakerConfig {
521 failure_threshold: 3,
522 window: Duration::from_millis(50),
523 cooldown: Duration::from_secs(30),
524 };
525 let mut b = CircuitBreaker::new("test_tool", config);
526
527 b.record_failure();
528 b.record_failure();
529 thread::sleep(Duration::from_millis(60));
530 b.record_failure();
531 assert_eq!(b.state(), BreakerState::Closed);
533 }
534
535 #[test]
536 fn registry_tracks_per_tool() {
537 let registry = CircuitBreakerRegistry::new(BreakerConfig {
538 failure_threshold: 2,
539 window: Duration::from_secs(10),
540 cooldown: Duration::from_secs(30),
541 });
542
543 registry.record_failure("tool_a");
545 registry.record_failure("tool_a");
546 assert_eq!(registry.state("tool_a"), BreakerState::Open);
547 assert!(registry.is_open("tool_a"));
548
549 assert_eq!(registry.state("tool_b"), BreakerState::Closed);
551 assert!(!registry.is_open("tool_b"));
552 }
553
554 #[test]
555 fn registry_reset() {
556 let registry = CircuitBreakerRegistry::new(BreakerConfig {
557 failure_threshold: 1,
558 window: Duration::from_secs(10),
559 cooldown: Duration::from_secs(30),
560 });
561
562 registry.record_failure("tool_x");
563 assert_eq!(registry.state("tool_x"), BreakerState::Open);
564 registry.reset("tool_x");
565 assert_eq!(registry.state("tool_x"), BreakerState::Closed);
566 }
567
568 #[test]
569 fn remaining_cooldown_decreases() {
570 let config = BreakerConfig {
571 failure_threshold: 1,
572 window: Duration::from_secs(10),
573 cooldown: Duration::from_millis(100),
574 };
575 let mut b = CircuitBreaker::new("test_tool", config);
576
577 b.record_failure();
578 let remaining = b.remaining_cooldown();
579 assert!(remaining > Duration::ZERO);
580 assert!(remaining <= Duration::from_millis(100));
581
582 thread::sleep(Duration::from_millis(60));
583 let remaining2 = b.remaining_cooldown();
584 assert!(remaining2 < remaining);
585 }
586
587 #[test]
588 fn large_window_doesnt_panic() {
589 let config = BreakerConfig {
592 failure_threshold: 1,
593 window: Duration::from_secs(u64::MAX / 1_000_000_000),
594 cooldown: Duration::from_secs(30),
595 };
596 let mut b = CircuitBreaker::new("test_tool", config);
597
598 b.record_failure();
600 assert_eq!(b.state(), BreakerState::Open);
601 }
602
603 #[test]
604 fn half_open_admits_single_probe() {
605 let config = BreakerConfig {
606 failure_threshold: 1,
607 window: Duration::from_secs(10),
608 cooldown: Duration::from_millis(20),
609 };
610 let mut b = CircuitBreaker::new("test_tool", config);
611 b.record_failure();
612 assert!(b.is_open());
613
614 thread::sleep(Duration::from_millis(30));
615 assert!(!b.is_open(), "first caller after cooldown is the probe");
616 assert!(
617 b.is_open(),
618 "concurrent callers must fast-fail while the probe is out"
619 );
620
621 b.record_success();
622 assert_eq!(b.state(), BreakerState::Closed);
623 assert!(!b.is_open());
624 }
625
626 #[test]
627 fn half_open_failure_counts_new_trip() {
628 let config = BreakerConfig {
629 failure_threshold: 1,
630 window: Duration::from_secs(10),
631 cooldown: Duration::from_millis(20),
632 };
633 let mut b = CircuitBreaker::new("test_tool", config);
634 b.record_failure();
635 assert_eq!(b.total_trips(), 1);
636
637 thread::sleep(Duration::from_millis(30));
638 assert!(!b.is_open()); b.record_failure(); assert_eq!(b.state(), BreakerState::Open);
642 assert_eq!(b.total_trips(), 2, "half-open re-open is a fresh trip");
643 }
644
645 #[test]
646 fn stale_probe_does_not_wedge_half_open() {
647 let config = BreakerConfig {
648 failure_threshold: 1,
649 window: Duration::from_secs(10),
650 cooldown: Duration::from_millis(20),
651 };
652 let mut b = CircuitBreaker::new("test_tool", config);
653 b.record_failure();
654
655 thread::sleep(Duration::from_millis(30));
656 assert!(!b.is_open()); thread::sleep(Duration::from_millis(30));
659 assert!(
660 !b.is_open(),
661 "a dead probe older than cooldown must be replaceable, not wedged"
662 );
663 assert_eq!(b.state(), BreakerState::HalfOpen);
664 }
665
666 #[test]
667 fn config_from_opt_parses_and_defaults_invalid_values() {
668 let parsed = BreakerConfig::from_opt(Some("3"), Some("2500"), Some("100"));
669 assert_eq!(parsed.failure_threshold, 3);
670 assert_eq!(parsed.window, Duration::from_millis(2500));
671 assert_eq!(parsed.cooldown, Duration::from_millis(100));
672
673 let defaults = BreakerConfig::from_opt(None, Some("not-a-number"), None);
674 assert_eq!(defaults.failure_threshold, 5);
675 assert_eq!(defaults.window, Duration::from_secs(10));
676 assert_eq!(defaults.cooldown, Duration::from_secs(30));
677 }
678
679 #[test]
680 fn snapshot_reports_open_and_trips() {
681 let registry = CircuitBreakerRegistry::new(BreakerConfig {
682 failure_threshold: 1,
683 window: Duration::from_secs(10),
684 cooldown: Duration::from_secs(30),
685 });
686 registry.record_failure("tool_a");
687 let snap = registry.snapshot();
688 assert_eq!(snap["open"], serde_json::json!(["tool_a"]));
689 assert_eq!(snap["half_open"], serde_json::json!([]));
690 assert_eq!(snap["trips"]["tool_a"], 1);
691 assert_eq!(
692 registry.snapshot()["open"].as_array().map(Vec::len),
693 Some(1)
694 );
695 }
696}