Skip to main content

wm_dispatch/
circuit_breaker.rs

1//! Circuit Breaker — Stoic resilience for tool dispatch.
2//!
3//! When a tool fails N times within M seconds, the breaker "opens" and
4//! subsequent calls fast-fail immediately. After a cooldown, the breaker
5//! enters "half-open" and allows a single probe call. If the probe succeeds,
6//! the breaker closes and normal flow resumes.
7//!
8//! States:
9//!   CLOSED   → Normal operation; failures are counted.
10//!   OPEN     → Fast-fail; returns immediately without calling the tool.
11//!   HALF_OPEN → One probe call allowed; success → CLOSED, failure → OPEN.
12//!
13//! Inspired by v2's circuit_breaker.py and the Koka algebraic effect handler,
14//! but implemented as a pure Rust state machine with monotonic clock.
15
16use std::collections::HashMap;
17use std::sync::RwLock;
18use std::time::{Duration, Instant};
19
20/// Circuit breaker state.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum BreakerState {
23    /// Normal operation; failures are counted.
24    Closed,
25    /// Fast-fail; calls return immediately.
26    Open,
27    /// One probe call allowed; success → Closed, failure → Open.
28    HalfOpen,
29}
30
31/// Configuration for a single circuit breaker.
32#[derive(Debug, Clone)]
33pub struct BreakerConfig {
34    /// Number of failures within the window before opening.
35    pub failure_threshold: u32,
36    /// Time window for counting failures.
37    pub window: Duration,
38    /// How long to stay open before transitioning to half-open.
39    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    /// Parse configuration from the environment:
54    /// `WM_BREAKER_THRESHOLD` (u32), `WM_BREAKER_WINDOW_MS` (u64),
55    /// `WM_BREAKER_COOLDOWN_MS` (u64). Unset or invalid fields keep the
56    /// default (invalid values warn, never fail startup), so the
57    /// no-variables path is byte-identical to [`BreakerConfig::default`].
58    #[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    /// Testable core of [`BreakerConfig::from_env`] — no environment reads.
68    #[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
95/// Parse one env value, falling back to `default` on absence or parse error
96/// (warn-only: a typo must not take the fleet down).
97fn 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
119/// A circuit breaker for a single tool.
120pub 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    /// Half-open single-probe guard: true while one probe call is out.
128    /// Prevents a burst of concurrent callers from all "probing" a
129    /// recovering tool (module doc promises a single probe).
130    probe_in_flight: bool,
131    /// When the in-flight probe started; a probe older than `cooldown` is
132    /// treated as dead (caller never recorded) so the breaker cannot wedge.
133    probe_started_at: Instant,
134}
135
136impl CircuitBreaker {
137    /// Create a new breaker for the given tool name.
138    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    /// Tool name this breaker protects.
152    #[must_use]
153    pub fn tool_name(&self) -> &str {
154        &self.tool_name
155    }
156
157    /// Current breaker state.
158    #[must_use]
159    pub const fn state(&self) -> BreakerState {
160        self.state
161    }
162
163    /// Total number of times this breaker has tripped from Closed to Open.
164    #[must_use]
165    pub const fn total_trips(&self) -> u64 {
166        self.total_trips
167    }
168
169    /// Check if the breaker is open (should fast-fail).
170    ///
171    /// Returns `true` if calls should be rejected, `false` if a call may proceed.
172    /// If the breaker is Open and the cooldown has elapsed, transitions to HalfOpen
173    /// and returns `false` (allowing one probe call).
174    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                    // This caller becomes the single probe.
182                    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 // Allow the probe call
189                } else {
190                    true
191                }
192            }
193            BreakerState::HalfOpen => {
194                // Exactly one probe at a time. A probe older than the
195                // cooldown is presumed dead (its caller never recorded)
196                // and may be replaced.
197                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 // A probe is already out — fast-fail the rest
202                } else {
203                    self.probe_in_flight = true;
204                    self.probe_started_at = Instant::now();
205                    false
206                }
207            }
208        }
209    }
210
211    /// Record a successful tool call.
212    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        // In Closed state, successes don't clear the failure window —
223        // they'll naturally expire.
224    }
225
226    /// Record a tool failure.
227    pub fn record_failure(&mut self) {
228        let now = Instant::now();
229
230        if self.state == BreakerState::HalfOpen {
231            // Probe failed → reopen (a fresh trip: the tool tried to
232            // recover and failed, so the trip count must reflect it).
233            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        // Prune old failures outside the window
246        // Use checked_sub to avoid panic if window > elapsed (e.g. very large window config)
247        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    /// Reset the breaker to Closed state (e.g. for manual recovery).
268    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    /// Remaining cooldown duration if Open, otherwise zero.
276    #[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
287/// Registry of circuit breakers, one per tool.
288pub struct CircuitBreakerRegistry {
289    breakers: RwLock<HashMap<String, CircuitBreaker>>,
290    default_config: BreakerConfig,
291}
292
293impl CircuitBreakerRegistry {
294    /// Create a new registry with the given default config.
295    #[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    /// Check if a tool's circuit breaker is open.
304    ///
305    /// Returns `true` if the call should be fast-failed.
306    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 // Poisoned lock — fail open (allow the call)
314        }
315    }
316
317    /// Record a successful call for the given tool.
318    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    /// Record a failure for the given tool.
327    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    /// Get the state of a tool's breaker (defaults to Closed if not tracked).
337    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    /// Remaining cooldown for an open breaker (`0` when closed or untracked).
348    /// Disclosed on the fast-fail error so callers can pace retries instead
349    /// of guessing how long "open" lasts.
350    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    /// Reset a specific tool's breaker.
362    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    /// Reset every tracked breaker (operator recovery). Returns how many
371    /// breakers were reset.
372    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    /// Get total trip count for a tool.
385    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    /// Create a registry from `WM_BREAKER_*` env configuration
394    /// (defaults when unset — see [`BreakerConfig::from_env`]).
395    #[must_use]
396    pub fn from_env() -> Self {
397        Self::new(BreakerConfig::from_env())
398    }
399
400    /// Read-only operator snapshot for `/status`: open + half-open tool
401    /// names and non-zero trip counts. Closed tools with zero trips are
402    /// omitted; no mutation, safe to call on any request path.
403    #[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        // Wait for cooldown
481        thread::sleep(Duration::from_millis(60));
482        assert!(!b.is_open()); // Transitions to HalfOpen, allows probe
483        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(); // → HalfOpen
498        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(); // → HalfOpen
514        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        // Only 1 failure in the current window — should still be closed
532        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        // Tool A fails twice → opens
544        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        // Tool B is still closed
550        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        // Very large window could cause checked_sub to return None
590        // (if window > elapsed since Instant epoch)
591        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        // Should not panic
599        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()); // probe admitted
639        b.record_failure(); // probe failed
640
641        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()); // probe #1 admitted, never records
657
658        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}