Skip to main content

lsp_max/primitives/
circuit_breaker.rs

1//! Circuit breaker protecting the rule-evaluation loop.
2//!
3//! State machine (ported from wasm4pm/src/self_healing.rs):
4//!
5//!   Closed ──(3 failures)──► Open
6//!   Open   ──(cooldown)────► HalfOpen
7//!   HalfOpen ─(success)───► Closed
8//!   HalfOpen ─(failure)───► Open
9
10use std::time::{Duration, Instant};
11
12/// Circuit state. `#[repr(u8)]` matches the wasm4pm encoding.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14#[repr(u8)]
15pub enum CircuitState {
16    /// Normal operation — requests flow through.
17    Closed = 0,
18    /// Recovery probe — one test request allowed through.
19    HalfOpen = 1,
20    /// Tripped — requests are rejected until cooldown elapses.
21    Open = 2,
22}
23
24/// Circuit breaker for the rule-evaluation loop.
25///
26/// Wrap in `Arc<parking_lot::Mutex<CircuitBreaker>>` when sharing across tasks.
27#[derive(Debug)]
28pub struct CircuitBreaker {
29    state: CircuitState,
30    failure_count: u32,
31    last_failure: Option<Instant>,
32    cooldown: Duration,
33    failure_threshold: u32,
34}
35
36impl Default for CircuitBreaker {
37    fn default() -> Self {
38        Self::new()
39    }
40}
41
42impl CircuitBreaker {
43    /// Create with default thresholds (3 failures, 30 s cooldown).
44    pub fn new() -> Self {
45        Self::with_config(3, Duration::from_secs(30))
46    }
47
48    /// Create with custom failure threshold and cooldown duration.
49    pub fn with_config(failure_threshold: u32, cooldown: Duration) -> Self {
50        Self {
51            state: CircuitState::Closed,
52            failure_count: 0,
53            last_failure: None,
54            cooldown,
55            failure_threshold,
56        }
57    }
58
59    /// Current state (read-only snapshot for logging/metrics).
60    pub fn state(&self) -> CircuitState {
61        self.state
62    }
63
64    /// Returns `true` when the caller is allowed to attempt the operation.
65    /// Transitions Open → HalfOpen when cooldown has elapsed.
66    pub fn is_allowed(&mut self) -> bool {
67        match self.state {
68            CircuitState::Closed | CircuitState::HalfOpen => true,
69            CircuitState::Open => {
70                let elapsed = self
71                    .last_failure
72                    .map(|t| t.elapsed())
73                    .unwrap_or(Duration::MAX);
74                if elapsed >= self.cooldown {
75                    tracing::info!(
76                        cooldown_elapsed_ms = elapsed.as_millis(),
77                        "CircuitBreaker: Open → HalfOpen"
78                    );
79                    self.state = CircuitState::HalfOpen;
80                    true
81                } else {
82                    false
83                }
84            }
85        }
86    }
87
88    /// Record a successful operation. Resets to Closed from HalfOpen.
89    pub fn record_success(&mut self) {
90        match self.state {
91            CircuitState::HalfOpen => {
92                tracing::info!("CircuitBreaker: HalfOpen → Closed");
93                self.state = CircuitState::Closed;
94                self.failure_count = 0;
95                self.last_failure = None;
96            }
97            CircuitState::Closed => {
98                self.failure_count = 0;
99            }
100            CircuitState::Open => {}
101        }
102    }
103
104    /// Record a failure. Trips Open after `failure_threshold` consecutive failures.
105    pub fn record_failure(&mut self) -> CircuitState {
106        self.failure_count += 1;
107        match self.state {
108            CircuitState::Closed => {
109                if self.failure_count >= self.failure_threshold {
110                    tracing::warn!(
111                        failure_count = self.failure_count,
112                        "CircuitBreaker: Closed → Open"
113                    );
114                    self.state = CircuitState::Open;
115                    self.last_failure = Some(Instant::now());
116                }
117            }
118            CircuitState::HalfOpen => {
119                tracing::warn!("CircuitBreaker: HalfOpen → Open (probe failed)");
120                self.state = CircuitState::Open;
121                self.last_failure = Some(Instant::now());
122            }
123            CircuitState::Open => {
124                self.last_failure = Some(Instant::now());
125            }
126        }
127        self.state
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn trips_open_after_threshold_failures() {
137        let mut cb = CircuitBreaker::with_config(3, Duration::from_secs(3600));
138        assert!(cb.is_allowed());
139        cb.record_failure();
140        cb.record_failure();
141        assert_eq!(cb.state(), CircuitState::Closed);
142        cb.record_failure(); // 3rd → Open
143        assert_eq!(cb.state(), CircuitState::Open);
144        assert!(!cb.is_allowed());
145    }
146
147    #[test]
148    fn half_open_to_closed_on_success() {
149        let mut cb = CircuitBreaker::with_config(1, Duration::from_millis(1));
150        cb.record_failure(); // trips Open
151        std::thread::sleep(Duration::from_millis(5));
152        assert!(cb.is_allowed()); // cooldown elapsed → HalfOpen
153        assert_eq!(cb.state(), CircuitState::HalfOpen);
154        cb.record_success();
155        assert_eq!(cb.state(), CircuitState::Closed);
156    }
157
158    #[test]
159    fn half_open_to_open_on_failure() {
160        let mut cb = CircuitBreaker::with_config(1, Duration::from_millis(1));
161        cb.record_failure();
162        std::thread::sleep(Duration::from_millis(5));
163        cb.is_allowed(); // → HalfOpen
164        cb.record_failure();
165        assert_eq!(cb.state(), CircuitState::Open);
166    }
167}