Skip to main content

execution_policy/
breaker.rs

1//! Circuit breaker: a fast lock-free closed-state gate check (atomic state load)
2//! with the bookkeeping mutex taken only on the once-per-call record path.
3
4use std::sync::Mutex;
5use std::sync::atomic::{AtomicU8, Ordering};
6use std::time::{Duration, Instant};
7
8use crate::classify::ErrorPredicate;
9use crate::error::BreakerState;
10
11const CLOSED: u8 = 0;
12const OPEN: u8 = 1;
13const HALF_OPEN: u8 = 2;
14
15/// What trips the breaker.
16#[derive(Debug, Clone, Copy)]
17enum Trip {
18    Consecutive {
19        threshold: u32,
20    },
21    FailureRatio {
22        ratio: f64,
23        min_throughput: u32,
24        window: Duration,
25    },
26}
27
28/// Circuit breaker configuration + (after `build`) live state.
29///
30/// Build with [`CircuitBreaker::consecutive_failures`] or
31/// [`CircuitBreaker::failure_ratio`]. `record_when` (optional) decides which
32/// operation errors count as breaker faults; by default every error counts.
33pub struct CircuitBreaker<E> {
34    trip: Trip,
35    open_for: Duration,
36    half_open_max_calls: u32,
37    record_when: Option<ErrorPredicate<E>>,
38}
39
40impl<E> std::fmt::Debug for CircuitBreaker<E> {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("CircuitBreaker")
43            .field("trip", &self.trip)
44            .field("open_for", &self.open_for)
45            .field("half_open_max_calls", &self.half_open_max_calls)
46            .field("record_when", &self.record_when.as_ref().map(|_| "<fn>"))
47            .finish()
48    }
49}
50
51impl<E> CircuitBreaker<E> {
52    /// Trip after `n` consecutive failures.
53    pub fn consecutive_failures(n: u32) -> Self {
54        Self {
55            trip: Trip::Consecutive {
56                threshold: n.max(1),
57            },
58            open_for: Duration::from_secs(30),
59            half_open_max_calls: 1,
60            record_when: None,
61        }
62    }
63
64    /// Trip when the failure ratio over a sampling window is exceeded.
65    pub fn failure_ratio() -> Self {
66        Self {
67            trip: Trip::FailureRatio {
68                ratio: 0.5,
69                min_throughput: 10,
70                window: Duration::from_secs(30),
71            },
72            open_for: Duration::from_secs(30),
73            half_open_max_calls: 1,
74            record_when: None,
75        }
76    }
77
78    pub fn failure_ratio_value(mut self, ratio: f64) -> Self {
79        if let Trip::FailureRatio { ratio: r, .. } = &mut self.trip {
80            *r = ratio.clamp(0.0, 1.0);
81        }
82        self
83    }
84    /// Alias matching the spec's fluent name `.failure_ratio(0.5)`.
85    pub fn ratio(self, ratio: f64) -> Self {
86        self.failure_ratio_value(ratio)
87    }
88    pub fn minimum_throughput(mut self, n: u32) -> Self {
89        if let Trip::FailureRatio { min_throughput, .. } = &mut self.trip {
90            *min_throughput = n;
91        }
92        self
93    }
94    pub fn sampling_window(mut self, d: Duration) -> Self {
95        if let Trip::FailureRatio { window, .. } = &mut self.trip {
96            *window = d;
97        }
98        self
99    }
100    pub fn open_for(mut self, d: Duration) -> Self {
101        self.open_for = d;
102        self
103    }
104    pub fn half_open_max_calls(mut self, n: u32) -> Self {
105        self.half_open_max_calls = n.max(1);
106        self
107    }
108    pub fn record_when(mut self, pred: impl Fn(&E) -> bool + Send + Sync + 'static) -> Self {
109        self.record_when = Some(Box::new(pred));
110        self
111    }
112
113    /// Compile into a live runtime state machine.
114    pub(crate) fn compile(self) -> (BreakerRuntime, Option<ErrorPredicate<E>>) {
115        let rt = BreakerRuntime::new(self.trip, self.open_for, self.half_open_max_calls);
116        (rt, self.record_when)
117    }
118}
119
120/// Live breaker state shared behind an `Arc`.
121#[derive(Debug)]
122pub(crate) struct BreakerRuntime {
123    state: AtomicU8,
124    trip: Trip,
125    open_for: Duration,
126    half_open_max_calls: u32,
127    inner: Mutex<Inner>,
128}
129
130#[derive(Debug)]
131struct Inner {
132    open_until: Option<Instant>,
133    consecutive_failures: u32,
134    half_open_in_flight: u32,
135    half_open_successes: u32,
136    window: Window,
137}
138
139impl BreakerRuntime {
140    fn new(trip: Trip, open_for: Duration, half_open_max_calls: u32) -> Self {
141        let buckets = match trip {
142            Trip::FailureRatio { window, .. } => Window::new(window),
143            Trip::Consecutive { .. } => Window::new(Duration::from_secs(1)),
144        };
145        Self {
146            state: AtomicU8::new(CLOSED),
147            trip,
148            open_for,
149            half_open_max_calls,
150            inner: Mutex::new(Inner {
151                open_until: None,
152                consecutive_failures: 0,
153                half_open_in_flight: 0,
154                half_open_successes: 0,
155                window: buckets,
156            }),
157        }
158    }
159
160    /// Current public state.
161    pub(crate) fn state(&self) -> BreakerState {
162        match self.state.load(Ordering::Acquire) {
163            OPEN => BreakerState::Open,
164            HALF_OPEN => BreakerState::HalfOpen,
165            _ => BreakerState::Closed,
166        }
167    }
168
169    /// Gate a call. `Ok(state)` allows it; `Err(())` means reject (circuit open).
170    /// Lock-free fast path when closed.
171    pub(crate) fn gate(&self, now: Instant) -> Result<BreakerState, ()> {
172        if self.state.load(Ordering::Acquire) == CLOSED {
173            return Ok(BreakerState::Closed);
174        }
175        let mut inner = self.inner.lock().unwrap();
176        match self.state.load(Ordering::Acquire) {
177            CLOSED => Ok(BreakerState::Closed),
178            OPEN => {
179                let ready = inner.open_until.map(|t| now >= t).unwrap_or(true);
180                if ready {
181                    self.state.store(HALF_OPEN, Ordering::Release);
182                    inner.half_open_in_flight = 1;
183                    inner.half_open_successes = 0;
184                    Ok(BreakerState::HalfOpen)
185                } else {
186                    Err(())
187                }
188            }
189            _ => {
190                // HALF_OPEN — admit up to half_open_max_calls probes.
191                if inner.half_open_in_flight < self.half_open_max_calls {
192                    inner.half_open_in_flight += 1;
193                    Ok(BreakerState::HalfOpen)
194                } else {
195                    Err(())
196                }
197            }
198        }
199    }
200
201    /// Record a success. Returns the new state if a transition occurred.
202    pub(crate) fn record_success(&self, now: Instant) -> Option<BreakerState> {
203        let mut inner = self.inner.lock().unwrap();
204        match self.state.load(Ordering::Acquire) {
205            HALF_OPEN => {
206                inner.half_open_in_flight = inner.half_open_in_flight.saturating_sub(1);
207                inner.half_open_successes += 1;
208                if inner.half_open_successes >= self.half_open_max_calls {
209                    self.close(&mut inner);
210                    return Some(BreakerState::Closed);
211                }
212                None
213            }
214            _ => {
215                inner.consecutive_failures = 0;
216                inner.window.record(now, false);
217                None
218            }
219        }
220    }
221
222    /// Record a failure. Returns the new state if a transition occurred.
223    pub(crate) fn record_failure(&self, now: Instant) -> Option<BreakerState> {
224        let mut inner = self.inner.lock().unwrap();
225        match self.state.load(Ordering::Acquire) {
226            HALF_OPEN => {
227                inner.half_open_in_flight = inner.half_open_in_flight.saturating_sub(1);
228                self.open(&mut inner, now);
229                Some(BreakerState::Open)
230            }
231            _ => {
232                inner.consecutive_failures += 1;
233                inner.window.record(now, true);
234                if self.should_trip(&mut inner, now) {
235                    self.open(&mut inner, now);
236                    Some(BreakerState::Open)
237                } else {
238                    None
239                }
240            }
241        }
242    }
243
244    fn should_trip(&self, inner: &mut Inner, now: Instant) -> bool {
245        match self.trip {
246            Trip::Consecutive { threshold } => inner.consecutive_failures >= threshold,
247            Trip::FailureRatio {
248                ratio,
249                min_throughput,
250                ..
251            } => {
252                let (failures, total) = inner.window.totals(now);
253                total >= min_throughput as u64 && (failures as f64 / total as f64) >= ratio
254            }
255        }
256    }
257
258    fn open(&self, inner: &mut Inner, now: Instant) {
259        self.state.store(OPEN, Ordering::Release);
260        inner.open_until = Some(now + self.open_for);
261        inner.half_open_successes = 0;
262        inner.half_open_in_flight = 0;
263    }
264
265    fn close(&self, inner: &mut Inner) {
266        self.state.store(CLOSED, Ordering::Release);
267        inner.consecutive_failures = 0;
268        inner.half_open_successes = 0;
269        inner.half_open_in_flight = 0;
270        inner.window.clear();
271    }
272}
273
274/// A time-bucketed sliding window of (failure, total) counts.
275#[derive(Debug)]
276struct Window {
277    span: Duration,
278    bucket: Duration,
279    buckets: Vec<(Instant, u64, u64)>, // (bucket_start, failures, total)
280}
281
282impl Window {
283    fn new(span: Duration) -> Self {
284        let n = 10u32;
285        let bucket = (span / n).max(Duration::from_millis(1));
286        Self {
287            span,
288            bucket,
289            buckets: Vec::with_capacity(n as usize + 1),
290        }
291    }
292
293    fn clear(&mut self) {
294        self.buckets.clear();
295    }
296
297    fn record(&mut self, now: Instant, failure: bool) {
298        self.evict(now);
299        let start = now;
300        match self.buckets.last_mut() {
301            Some((b_start, fails, total)) if now.duration_since(*b_start) < self.bucket => {
302                *total += 1;
303                if failure {
304                    *fails += 1;
305                }
306            }
307            _ => {
308                self.buckets.push((start, u64::from(failure), 1));
309            }
310        }
311    }
312
313    fn totals(&mut self, now: Instant) -> (u64, u64) {
314        self.evict(now);
315        self.buckets
316            .iter()
317            .fold((0, 0), |(f, t), (_, fails, total)| (f + fails, t + total))
318    }
319
320    fn evict(&mut self, now: Instant) {
321        let cutoff = now.checked_sub(self.span);
322        if let Some(cutoff) = cutoff {
323            self.buckets.retain(|(start, _, _)| *start >= cutoff);
324        }
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    fn t0() -> Instant {
333        Instant::now()
334    }
335
336    #[test]
337    fn consecutive_trips_after_threshold() {
338        let (rt, _) = CircuitBreaker::<()>::consecutive_failures(3)
339            .open_for(Duration::from_secs(10))
340            .compile();
341        let now = t0();
342        assert_eq!(rt.gate(now), Ok(BreakerState::Closed));
343        rt.record_failure(now);
344        rt.record_failure(now);
345        assert_eq!(rt.state(), BreakerState::Closed);
346        rt.record_failure(now); // 3rd → trip
347        assert_eq!(rt.state(), BreakerState::Open);
348        assert_eq!(rt.gate(now), Err(())); // rejected while open
349    }
350
351    #[test]
352    fn success_resets_consecutive() {
353        let (rt, _) = CircuitBreaker::<()>::consecutive_failures(3).compile();
354        let now = t0();
355        rt.record_failure(now);
356        rt.record_failure(now);
357        rt.record_success(now);
358        rt.record_failure(now);
359        rt.record_failure(now);
360        assert_eq!(rt.state(), BreakerState::Closed); // never hit 3 in a row
361    }
362
363    #[test]
364    fn half_open_probe_then_close_on_success() {
365        let (rt, _) = CircuitBreaker::<()>::consecutive_failures(2)
366            .open_for(Duration::from_secs(5))
367            .half_open_max_calls(1)
368            .compile();
369        let now = t0();
370        rt.record_failure(now);
371        rt.record_failure(now);
372        assert_eq!(rt.state(), BreakerState::Open);
373        // Before open_for elapses: still rejected.
374        assert_eq!(rt.gate(now + Duration::from_secs(1)), Err(()));
375        // After open_for: half-open probe admitted.
376        let later = now + Duration::from_secs(6);
377        assert_eq!(rt.gate(later), Ok(BreakerState::HalfOpen));
378        rt.record_success(later);
379        assert_eq!(rt.state(), BreakerState::Closed);
380    }
381
382    #[test]
383    fn half_open_failure_reopens() {
384        let (rt, _) = CircuitBreaker::<()>::consecutive_failures(1)
385            .open_for(Duration::from_secs(5))
386            .compile();
387        let now = t0();
388        rt.record_failure(now);
389        assert_eq!(rt.state(), BreakerState::Open);
390        let later = now + Duration::from_secs(6);
391        assert_eq!(rt.gate(later), Ok(BreakerState::HalfOpen));
392        rt.record_failure(later);
393        assert_eq!(rt.state(), BreakerState::Open);
394    }
395
396    #[test]
397    fn failure_ratio_trips() {
398        let (rt, _) = CircuitBreaker::<()>::failure_ratio()
399            .ratio(0.5)
400            .minimum_throughput(4)
401            .sampling_window(Duration::from_secs(10))
402            .compile();
403        let now = t0();
404        // 2 ok, 2 fail over 4 calls = 50% ratio, throughput 4 → trip.
405        rt.record_success(now);
406        rt.record_success(now);
407        rt.record_failure(now);
408        rt.record_failure(now);
409        assert_eq!(rt.state(), BreakerState::Open);
410    }
411
412    #[test]
413    fn failure_ratio_respects_min_throughput() {
414        let (rt, _) = CircuitBreaker::<()>::failure_ratio()
415            .ratio(0.5)
416            .minimum_throughput(10)
417            .compile();
418        let now = t0();
419        rt.record_failure(now);
420        rt.record_failure(now); // 100% but only 2 calls < min 10
421        assert_eq!(rt.state(), BreakerState::Closed);
422    }
423
424    #[test]
425    fn record_when_compiles_predicate() {
426        let cb = CircuitBreaker::<i32>::consecutive_failures(1).record_when(|e: &i32| *e >= 500);
427        let (_rt, record_when) = cb.compile();
428        let p = record_when.expect("predicate present");
429        assert!(p(&503));
430        assert!(!p(&404));
431    }
432}