Skip to main content

stygian_proxy/
circuit_breaker.rs

1//! Per-proxy circuit breaker.
2//!
3//! State machine:
4//!
5//! ```text
6//! CLOSED ──(failures ≥ threshold)──► OPEN
7//!   ▲                                  │
8//!   │                     (elapsed > half_open_after)
9//!   │                                  ▼
10//! (success)                        HALF_OPEN
11//!   └──────────────────────────────────┘
12//!                    or
13//! HALF_OPEN ──(failure)──► OPEN  (timer reset)
14//! ```
15
16use std::sync::atomic::{AtomicU8, AtomicU32, AtomicU64, Ordering};
17use std::time::{SystemTime, UNIX_EPOCH};
18
19pub const STATE_CLOSED: u8 = 0;
20pub const STATE_OPEN: u8 = 1;
21pub const STATE_HALF_OPEN: u8 = 2;
22
23/// Lightweight, lock-free per-proxy circuit breaker.
24///
25/// All fields are atomics so many tasks can call `record_failure` /
26/// `record_success` / `is_available` concurrently without a mutex.
27pub struct CircuitBreaker {
28    state: AtomicU8,
29    failure_count: AtomicU32,
30    /// Milliseconds since UNIX_EPOCH of the last recorded failure.
31    last_failure: AtomicU64,
32    threshold: u32,
33    half_open_after_ms: u64,
34}
35
36impl CircuitBreaker {
37    /// Create a new breaker from config parameters.
38    pub fn new(threshold: u32, half_open_after_ms: u64) -> Self {
39        Self {
40            state: AtomicU8::new(STATE_CLOSED),
41            failure_count: AtomicU32::new(0),
42            last_failure: AtomicU64::new(0),
43            threshold,
44            half_open_after_ms,
45        }
46    }
47
48    /// Current state as a u8 constant.
49    #[inline]
50    pub fn state(&self) -> u8 {
51        self.state.load(Ordering::Acquire)
52    }
53
54    /// Returns `true` when the proxy may be used (Closed or HalfOpen).
55    ///
56    /// When the circuit is Open and enough time has elapsed since the last
57    /// failure the breaker transitions to HalfOpen and returns `true`.
58    pub fn is_available(&self) -> bool {
59        match self.state.load(Ordering::Acquire) {
60            STATE_CLOSED => true,
61            STATE_HALF_OPEN => true,
62            STATE_OPEN => {
63                let elapsed_ms = now_ms().saturating_sub(self.last_failure.load(Ordering::Acquire));
64                if elapsed_ms >= self.half_open_after_ms {
65                    // Try to transition Open → HalfOpen.  Another thread may
66                    // get there first — both outcomes are fine: the proxy is
67                    // available in HalfOpen regardless of which thread won.
68                    let _ = self.state.compare_exchange(
69                        STATE_OPEN,
70                        STATE_HALF_OPEN,
71                        Ordering::AcqRel,
72                        Ordering::Acquire,
73                    );
74                    true
75                } else {
76                    false
77                }
78            }
79            _ => false,
80        }
81    }
82
83    /// Record a successful request.
84    ///
85    /// When the circuit is in HalfOpen this resets the failure count and
86    /// transitions back to Closed.
87    pub fn record_success(&self) {
88        if self
89            .state
90            .compare_exchange(
91                STATE_HALF_OPEN,
92                STATE_CLOSED,
93                Ordering::AcqRel,
94                Ordering::Acquire,
95            )
96            .is_ok()
97        {
98            self.failure_count.store(0, Ordering::Release);
99        }
100    }
101
102    /// Record a failed request.
103    ///
104    /// In Closed: if the incremented count reaches the threshold the circuit
105    /// trips to Open.  In HalfOpen: immediately trips back to Open and resets
106    /// the timer.
107    pub fn record_failure(&self) {
108        let count = self.failure_count.fetch_add(1, Ordering::AcqRel) + 1;
109        self.last_failure.store(now_ms(), Ordering::Release);
110
111        let current_state = self.state.load(Ordering::Acquire);
112        if current_state == STATE_CLOSED && count >= self.threshold {
113            // Closed → Open
114            let _ = self.state.compare_exchange(
115                STATE_CLOSED,
116                STATE_OPEN,
117                Ordering::AcqRel,
118                Ordering::Acquire,
119            );
120        } else if current_state == STATE_HALF_OPEN {
121            // HalfOpen → Open (probe failed)
122            let _ = self.state.compare_exchange(
123                STATE_HALF_OPEN,
124                STATE_OPEN,
125                Ordering::AcqRel,
126                Ordering::Acquire,
127            );
128        }
129    }
130}
131
132#[inline]
133fn now_ms() -> u64 {
134    SystemTime::now()
135        .duration_since(UNIX_EPOCH)
136        .unwrap_or_default()
137        .as_millis() as u64
138}
139
140// ─────────────────────────────────────────────────────────────────────────────
141// Tests
142// ─────────────────────────────────────────────────────────────────────────────
143
144#[cfg(test)]
145mod tests {
146    use std::sync::Arc;
147
148    use super::*;
149
150    fn breaker(threshold: u32, half_open_after_ms: u64) -> CircuitBreaker {
151        CircuitBreaker::new(threshold, half_open_after_ms)
152    }
153
154    #[test]
155    fn failures_open_circuit() {
156        let cb = breaker(3, 30_000);
157        assert_eq!(cb.state(), STATE_CLOSED);
158        cb.record_failure();
159        cb.record_failure();
160        assert_eq!(cb.state(), STATE_CLOSED, "not tripped yet");
161        cb.record_failure();
162        assert_eq!(cb.state(), STATE_OPEN, "should be open after threshold");
163        assert!(!cb.is_available());
164    }
165
166    #[test]
167    fn half_open_after_elapsed() {
168        let cb = breaker(1, 0); // half_open_after = 0 ms → immediate
169        cb.record_failure();
170        assert_eq!(cb.state(), STATE_OPEN);
171        // With half_open_after_ms = 0, any call to is_available should
172        // transition to HalfOpen because elapsed ≥ 0 is always true.
173        assert!(cb.is_available(), "should transition to half-open");
174        assert_eq!(cb.state(), STATE_HALF_OPEN);
175    }
176
177    #[test]
178    fn success_in_half_open_closes_circuit() {
179        let cb = breaker(1, 0);
180        cb.record_failure();
181        assert!(cb.is_available()); // → HalfOpen
182        cb.record_success();
183        assert_eq!(cb.state(), STATE_CLOSED);
184        assert!(cb.is_available());
185    }
186
187    #[test]
188    fn failure_in_half_open_reopens() {
189        let cb = breaker(1, 0);
190        cb.record_failure();
191        assert!(cb.is_available()); // → HalfOpen
192        cb.record_failure(); // probe failed → back to Open
193        assert_eq!(cb.state(), STATE_OPEN);
194    }
195
196    #[test]
197    fn concurrent_failures_open_circuit() {
198        use std::thread;
199        let cb = Arc::new(breaker(5, 30_000));
200        let handles: Vec<_> = (0..100)
201            .map(|_| {
202                let cb = Arc::clone(&cb);
203                thread::spawn(move || cb.record_failure())
204            })
205            .collect();
206        for h in handles {
207            h.join().unwrap();
208        }
209        assert_eq!(cb.state(), STATE_OPEN);
210        assert!(cb.failure_count.load(Ordering::Relaxed) >= 5);
211    }
212}