Skip to main content

agent_base/engine/
circuit_breaker.rs

1use std::sync::atomic::{AtomicUsize, Ordering};
2use std::sync::Mutex;
3use std::time::Instant;
4
5/// Circuit breaker states.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum CircuitState {
8    /// Normal operation — requests pass through.
9    Closed,
10    /// Too many failures — requests are rejected.
11    Open,
12    /// Testing recovery — a single request is allowed through.
13    HalfOpen,
14}
15
16/// A circuit breaker that tracks consecutive failures and temporarily disables
17/// execution when a threshold is exceeded.
18///
19/// State transitions:
20/// - `Closed` → `Open`: when `consecutive_failures >= failure_threshold`
21/// - `Open` → `HalfOpen`: after `open_duration_ms` has elapsed
22/// - `HalfOpen` → `Closed`: on success
23/// - `HalfOpen` → `Open`: on failure (resets timer)
24///
25/// Designed to be held via `Arc<CircuitBreaker>` and referenced via
26/// `Weak<CircuitBreaker>` from executors, so the breaker can be dropped
27/// independently without leaking memory.
28#[derive(Debug)]
29pub struct CircuitBreaker {
30    failure_threshold: usize,
31    open_duration_ms: u64,
32    state: Mutex<CircuitState>,
33    consecutive_failures: AtomicUsize,
34    opened_at: Mutex<Option<Instant>>,
35}
36
37impl CircuitBreaker {
38    /// Create a new circuit breaker.
39    ///
40    /// - `failure_threshold`: number of consecutive failures before opening.
41    /// - `open_duration_ms`: how long (in milliseconds) to stay open before
42    ///   transitioning to half-open.
43    pub fn new(failure_threshold: usize, open_duration_ms: u64) -> Self {
44        Self {
45            failure_threshold,
46            open_duration_ms,
47            state: Mutex::new(CircuitState::Closed),
48            consecutive_failures: AtomicUsize::new(0),
49            opened_at: Mutex::new(None),
50        }
51    }
52
53    /// Record a successful operation.
54    ///
55    /// Resets the consecutive failure counter and transitions to `Closed`.
56    pub fn record_success(&self) {
57        self.consecutive_failures.store(0, Ordering::SeqCst);
58        let mut state = self.state.lock().unwrap();
59        let prev = *state;
60        *state = CircuitState::Closed;
61        *self.opened_at.lock().unwrap() = None;
62        if prev != CircuitState::Closed {
63            tracing::info!(from = ?prev, to = ?CircuitState::Closed, "circuit breaker state changed");
64        }
65    }
66
67    /// Record a failed operation.
68    ///
69    /// Increments the failure counter. If the threshold is reached,
70    /// transitions to `Open` and records the timestamp.
71    pub fn record_failure(&self) {
72        let count = self.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1;
73        if count >= self.failure_threshold {
74            let mut state = self.state.lock().unwrap();
75            let prev = *state;
76            if prev != CircuitState::Open {
77                *state = CircuitState::Open;
78                *self.opened_at.lock().unwrap() = Some(Instant::now());
79                tracing::warn!(
80                    from = ?prev,
81                    to = ?CircuitState::Open,
82                    consecutive_failures = count,
83                    threshold = self.failure_threshold,
84                    "circuit breaker opened"
85                );
86            }
87        }
88    }
89
90    /// Check whether the circuit breaker allows execution.
91    ///
92    /// Returns `true` if the circuit is `Closed` or has transitioned from
93    /// `Open` to `HalfOpen` (enough time has passed). Returns `false` if
94    /// the circuit is still `Open`.
95    ///
96    /// When returning `true` in the `HalfOpen` state, the caller should
97    /// proceed with a single trial request and call `record_success` or
98    /// `record_failure` accordingly.
99    pub fn is_available(&self) -> bool {
100        let mut state = self.state.lock().unwrap();
101        match *state {
102            CircuitState::Closed => true,
103            CircuitState::HalfOpen => true,
104            CircuitState::Open => {
105                // Check if enough time has passed to transition to half-open
106                let opened_at = self.opened_at.lock().unwrap();
107                if let Some(at) = *opened_at
108                    && at.elapsed().as_millis() >= self.open_duration_ms as u128
109                {
110                    *state = CircuitState::HalfOpen;
111                    tracing::info!(
112                        from = ?CircuitState::Open,
113                        to = ?CircuitState::HalfOpen,
114                        "circuit breaker half-open, allowing trial request"
115                    );
116                    return true;
117                }
118                false
119            }
120        }
121    }
122
123    /// Get the current state.
124    pub fn state(&self) -> CircuitState {
125        *self.state.lock().unwrap()
126    }
127
128    /// Get the current consecutive failure count.
129    pub fn consecutive_failures(&self) -> usize {
130        self.consecutive_failures.load(Ordering::SeqCst)
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use std::sync::Arc;
138    use std::thread;
139    use std::time::Duration;
140
141    #[test]
142    fn new_breaker_is_closed() {
143        let cb = CircuitBreaker::new(3, 1000);
144        assert_eq!(cb.state(), CircuitState::Closed);
145        assert!(cb.is_available());
146        assert_eq!(cb.consecutive_failures(), 0);
147    }
148
149    #[test]
150    fn stays_closed_below_threshold() {
151        let cb = CircuitBreaker::new(3, 1000);
152        cb.record_failure();
153        assert_eq!(cb.state(), CircuitState::Closed);
154        assert!(cb.is_available());
155        cb.record_failure();
156        assert_eq!(cb.state(), CircuitState::Closed);
157        assert!(cb.is_available());
158    }
159
160    #[test]
161    fn opens_at_threshold() {
162        let cb = CircuitBreaker::new(3, 1000);
163        cb.record_failure();
164        cb.record_failure();
165        cb.record_failure();
166        assert_eq!(cb.state(), CircuitState::Open);
167        assert!(!cb.is_available());
168    }
169
170    #[test]
171    fn success_resets_counter() {
172        let cb = CircuitBreaker::new(3, 1000);
173        cb.record_failure();
174        cb.record_failure();
175        cb.record_success();
176        assert_eq!(cb.state(), CircuitState::Closed);
177        assert_eq!(cb.consecutive_failures(), 0);
178    }
179
180    #[test]
181    fn transitions_to_half_open_after_duration() {
182        let cb = CircuitBreaker::new(2, 50); // 50ms open duration
183        cb.record_failure();
184        cb.record_failure();
185        assert_eq!(cb.state(), CircuitState::Open);
186        assert!(!cb.is_available());
187
188        thread::sleep(Duration::from_millis(60));
189        assert!(cb.is_available());
190        assert_eq!(cb.state(), CircuitState::HalfOpen);
191    }
192
193    #[test]
194    fn half_open_success_closes() {
195        let cb = CircuitBreaker::new(2, 50);
196        cb.record_failure();
197        cb.record_failure();
198        thread::sleep(Duration::from_millis(60));
199        assert!(cb.is_available()); // transitions to HalfOpen
200        cb.record_success();
201        assert_eq!(cb.state(), CircuitState::Closed);
202    }
203
204    #[test]
205    fn half_open_failure_reopens() {
206        let cb = CircuitBreaker::new(2, 50);
207        cb.record_failure();
208        cb.record_failure();
209        thread::sleep(Duration::from_millis(60));
210        assert!(cb.is_available()); // HalfOpen
211        cb.record_failure();
212        assert_eq!(cb.state(), CircuitState::Open);
213        assert!(!cb.is_available());
214    }
215
216    #[test]
217    fn weak_reference_allows_cleanup() {
218        let strong = Arc::new(CircuitBreaker::new(3, 1000));
219        let weak = Arc::downgrade(&strong);
220        assert!(weak.upgrade().is_some());
221
222        drop(strong);
223        assert!(weak.upgrade().is_none());
224    }
225}