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    #[must_use]
39    pub const fn new(threshold: u32, half_open_after_ms: u64) -> Self {
40        Self {
41            state: AtomicU8::new(STATE_CLOSED),
42            failure_count: AtomicU32::new(0),
43            last_failure: AtomicU64::new(0),
44            threshold,
45            half_open_after_ms,
46        }
47    }
48
49    /// Current state as a u8 constant.
50    #[inline]
51    pub fn state(&self) -> u8 {
52        self.state.load(Ordering::Acquire)
53    }
54
55    /// Returns `true` when the proxy may be used (`Closed` or `HalfOpen`).
56    ///
57    /// When the circuit is Open and enough time has elapsed since the last
58    /// failure the breaker transitions to `HalfOpen` and returns `true`.
59    pub fn is_available(&self) -> bool {
60        match self.state.load(Ordering::Acquire) {
61            STATE_CLOSED | 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()
138        .try_into()
139        .unwrap_or(u64::MAX)
140}
141
142// ─────────────────────────────────────────────────────────────────────────────
143// Tests
144// ─────────────────────────────────────────────────────────────────────────────
145
146#[cfg(test)]
147mod tests {
148    use std::sync::Arc;
149
150    use super::*;
151
152    fn breaker(threshold: u32, half_open_after_ms: u64) -> CircuitBreaker {
153        CircuitBreaker::new(threshold, half_open_after_ms)
154    }
155
156    #[test]
157    fn failures_open_circuit() {
158        let cb = breaker(3, 30_000);
159        assert_eq!(cb.state(), STATE_CLOSED);
160        cb.record_failure();
161        cb.record_failure();
162        assert_eq!(cb.state(), STATE_CLOSED, "not tripped yet");
163        cb.record_failure();
164        assert_eq!(cb.state(), STATE_OPEN, "should be open after threshold");
165        assert!(!cb.is_available());
166    }
167
168    #[test]
169    fn half_open_after_elapsed() {
170        let cb = breaker(1, 0); // half_open_after = 0 ms → immediate
171        cb.record_failure();
172        assert_eq!(cb.state(), STATE_OPEN);
173        // With half_open_after_ms = 0, any call to is_available should
174        // transition to HalfOpen because elapsed ≥ 0 is always true.
175        assert!(cb.is_available(), "should transition to half-open");
176        assert_eq!(cb.state(), STATE_HALF_OPEN);
177    }
178
179    #[test]
180    fn success_in_half_open_closes_circuit() {
181        let cb = breaker(1, 0);
182        cb.record_failure();
183        assert!(cb.is_available()); // → HalfOpen
184        cb.record_success();
185        assert_eq!(cb.state(), STATE_CLOSED);
186        assert!(cb.is_available());
187    }
188
189    #[test]
190    fn failure_in_half_open_reopens() {
191        let cb = breaker(1, 0);
192        cb.record_failure();
193        assert!(cb.is_available()); // → HalfOpen
194        cb.record_failure(); // probe failed → back to Open
195        assert_eq!(cb.state(), STATE_OPEN);
196    }
197
198    #[test]
199    fn concurrent_failures_open_circuit() {
200        use std::thread;
201        let cb = Arc::new(breaker(5, 30_000));
202        let handles: Vec<_> = (0..100)
203            .map(|_| {
204                let cb = Arc::clone(&cb);
205                thread::spawn(move || cb.record_failure())
206            })
207            .collect();
208        for h in handles {
209            assert!(h.join().is_ok(), "worker thread should not panic");
210        }
211        assert_eq!(cb.state(), STATE_OPEN);
212        assert!(cb.failure_count.load(Ordering::Relaxed) >= 5);
213    }
214}