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    /// Reset a specific tool's breaker.
348    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    /// Reset every tracked breaker (operator recovery). Returns how many
357    /// breakers were reset.
358    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    /// Get total trip count for a tool.
371    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    /// Create a registry from `WM_BREAKER_*` env configuration
380    /// (defaults when unset — see [`BreakerConfig::from_env`]).
381    #[must_use]
382    pub fn from_env() -> Self {
383        Self::new(BreakerConfig::from_env())
384    }
385
386    /// Read-only operator snapshot for `/status`: open + half-open tool
387    /// names and non-zero trip counts. Closed tools with zero trips are
388    /// omitted; no mutation, safe to call on any request path.
389    #[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        // Wait for cooldown
467        thread::sleep(Duration::from_millis(60));
468        assert!(!b.is_open()); // Transitions to HalfOpen, allows probe
469        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(); // → HalfOpen
484        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(); // → HalfOpen
500        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        // Only 1 failure in the current window — should still be closed
518        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        // Tool A fails twice → opens
530        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        // Tool B is still closed
536        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        // Very large window could cause checked_sub to return None
576        // (if window > elapsed since Instant epoch)
577        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        // Should not panic
585        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()); // probe admitted
625        b.record_failure(); // probe failed
626
627        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()); // probe #1 admitted, never records
643
644        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}