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
52/// A circuit breaker for a single tool.
53pub 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    /// Create a new breaker for the given tool name.
64    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    /// Tool name this breaker protects.
76    #[must_use]
77    pub fn tool_name(&self) -> &str {
78        &self.tool_name
79    }
80
81    /// Current breaker state.
82    #[must_use]
83    pub const fn state(&self) -> BreakerState {
84        self.state
85    }
86
87    /// Total number of times this breaker has tripped from Closed to Open.
88    #[must_use]
89    pub const fn total_trips(&self) -> u64 {
90        self.total_trips
91    }
92
93    /// Check if the breaker is open (should fast-fail).
94    ///
95    /// Returns `true` if calls should be rejected, `false` if a call may proceed.
96    /// If the breaker is Open and the cooldown has elapsed, transitions to HalfOpen
97    /// and returns `false` (allowing one probe call).
98    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 // Allow one probe call
110                } else {
111                    true
112                }
113            }
114            BreakerState::HalfOpen => false, // Allow one call through
115        }
116    }
117
118    /// Record a successful tool call.
119    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        // In Closed state, successes don't clear the failure window —
129        // they'll naturally expire.
130    }
131
132    /// Record a tool failure.
133    pub fn record_failure(&mut self) {
134        let now = Instant::now();
135
136        if self.state == BreakerState::HalfOpen {
137            // Probe failed → reopen
138            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        // Prune old failures outside the window
148        // Use checked_sub to avoid panic if window > elapsed (e.g. very large window config)
149        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    /// Reset the breaker to Closed state (e.g. for manual recovery).
169    pub fn reset(&mut self) {
170        self.state = BreakerState::Closed;
171        self.failure_timestamps.clear();
172        self.total_trips = 0;
173    }
174
175    /// Remaining cooldown duration if Open, otherwise zero.
176    #[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
187/// Registry of circuit breakers, one per tool.
188pub struct CircuitBreakerRegistry {
189    breakers: RwLock<HashMap<String, CircuitBreaker>>,
190    default_config: BreakerConfig,
191}
192
193impl CircuitBreakerRegistry {
194    /// Create a new registry with the given default config.
195    #[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    /// Check if a tool's circuit breaker is open.
204    ///
205    /// Returns `true` if the call should be fast-failed.
206    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 // Poisoned lock — fail open (allow the call)
214        }
215    }
216
217    /// Record a successful call for the given tool.
218    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    /// Record a failure for the given tool.
227    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    /// Get the state of a tool's breaker (defaults to Closed if not tracked).
237    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    /// Reset a specific tool's breaker.
248    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    /// Get total trip count for a tool.
257    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        // Wait for cooldown
316        thread::sleep(Duration::from_millis(60));
317        assert!(!b.is_open()); // Transitions to HalfOpen, allows probe
318        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(); // → HalfOpen
333        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(); // → HalfOpen
349        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        // Only 1 failure in the current window — should still be closed
367        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        // Tool A fails twice → opens
379        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        // Tool B is still closed
385        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        // Very large window could cause checked_sub to return None
425        // (if window > elapsed since Instant epoch)
426        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        // Should not panic
434        b.record_failure();
435        assert_eq!(b.state(), BreakerState::Open);
436    }
437}