Skip to main content

connpool/resilience/
breaker.rs

1//! Circuit breaker: `closed -> open -> half-open`, counting one failure per logical creation.
2
3use std::sync::{Arc, Mutex};
4
5use crate::core::clock::{Clock, SystemClock};
6use crate::core::errors::PoolError;
7
8/// The circuit breaker's state-machine position.
9///
10/// ```text
11/// CLOSED --(N consecutive failures)--> OPEN --(recovery_timeout)--> HALF_OPEN
12///    ^                                   ^                              |
13///    |                                   +------ any probe fails -------+
14///    +------------ enough probes succeed ---------------------------------+
15/// ```
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum BreakerState {
18    /// Calls flow normally.
19    Closed,
20    /// Calls are rejected to protect a failing backend.
21    Open,
22    /// A limited number of probe calls are allowed to test recovery.
23    HalfOpen,
24}
25
26impl BreakerState {
27    /// The snake_case wire string for this state.
28    pub fn as_str(&self) -> &'static str {
29        match self {
30            BreakerState::Closed => "closed",
31            BreakerState::Open => "open",
32            BreakerState::HalfOpen => "half_open",
33        }
34    }
35}
36
37impl std::fmt::Display for BreakerState {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.write_str(self.as_str())
40    }
41}
42
43/// Configuration for a [`CircuitBreaker`].
44#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct BreakerConfig {
46    /// Consecutive failures that trip the breaker.
47    pub failure_threshold: u32,
48    /// Seconds to stay `Open` before probing.
49    pub recovery_timeout: f64,
50    /// Concurrent probe calls allowed while `HalfOpen`.
51    pub half_open_max_calls: u32,
52    /// Probe successes needed to close again.
53    pub success_threshold: u32,
54}
55
56impl Default for BreakerConfig {
57    fn default() -> Self {
58        Self {
59            failure_threshold: 5,
60            recovery_timeout: 30.0,
61            half_open_max_calls: 1,
62            success_threshold: 1,
63        }
64    }
65}
66
67impl BreakerConfig {
68    /// Checks the configuration is well-formed.
69    pub fn validate(&self) -> Result<(), PoolError> {
70        if self.failure_threshold < 1 || self.half_open_max_calls < 1 || self.success_threshold < 1
71        {
72            return Err(PoolError::Config("breaker thresholds must be >= 1".into()));
73        }
74        if self.recovery_timeout < 0.0 {
75            return Err(PoolError::Config(
76                "breaker.recovery_timeout must be >= 0".into(),
77            ));
78        }
79        Ok(())
80    }
81}
82
83/// A callback invoked (outside the lock) whenever the breaker changes state.
84pub type OnStateChange = Box<dyn Fn(BreakerState, BreakerState) + Send + Sync>;
85
86struct Inner {
87    state: BreakerState,
88    failures: u32,
89    opened_at: f64,
90    probes_inflight: u32,
91    probe_successes: u32,
92}
93
94/// A thread-safe circuit breaker. Usage: [`before_call`](CircuitBreaker::before_call) -> do
95/// work -> [`record_success`](CircuitBreaker::record_success) /
96/// [`record_failure`](CircuitBreaker::record_failure) /
97/// [`record_abort`](CircuitBreaker::record_abort).
98pub struct CircuitBreaker {
99    cfg: BreakerConfig,
100    clock: Arc<dyn Clock>,
101    on_change: Option<OnStateChange>,
102    inner: Mutex<Inner>,
103}
104
105impl CircuitBreaker {
106    /// Builds a breaker. `clock` defaults to [`SystemClock`] when `None`.
107    pub fn new(
108        config: BreakerConfig,
109        clock: Option<Arc<dyn Clock>>,
110        on_state_change: Option<OnStateChange>,
111    ) -> Self {
112        Self {
113            cfg: config,
114            clock: clock.unwrap_or_else(|| Arc::new(SystemClock::new())),
115            on_change: on_state_change,
116            inner: Mutex::new(Inner {
117                state: BreakerState::Closed,
118                failures: 0,
119                opened_at: 0.0,
120                probes_inflight: 0,
121                probe_successes: 0,
122            }),
123        }
124    }
125
126    /// The current state, refreshing `Open -> HalfOpen` if the recovery timeout elapsed.
127    pub fn state(&self) -> BreakerState {
128        let mut g = self.inner.lock().unwrap();
129        let transition = self.refresh(&mut g);
130        let state = g.state;
131        drop(g);
132        self.emit(transition);
133        state
134    }
135
136    /// Returns [`PoolError::CircuitOpen`] if the call must not be attempted.
137    pub fn before_call(&self) -> Result<(), PoolError> {
138        let mut call_err = None;
139        let mut g = self.inner.lock().unwrap();
140        let transition = self.refresh(&mut g);
141        match g.state {
142            BreakerState::Open => {
143                // refresh() already moved us out of OPEN once recovery elapsed, so wait > 0.
144                let wait =
145                    (self.cfg.recovery_timeout - (self.clock.monotonic() - g.opened_at)).max(0.0);
146                call_err = Some(PoolError::CircuitOpen { retry_after: wait });
147            }
148            BreakerState::HalfOpen => {
149                if g.probes_inflight >= self.cfg.half_open_max_calls {
150                    call_err = Some(PoolError::CircuitOpen { retry_after: 0.0 });
151                } else {
152                    g.probes_inflight += 1;
153                }
154            }
155            BreakerState::Closed => {}
156        }
157        drop(g);
158        self.emit(transition);
159        match call_err {
160            Some(e) => Err(e),
161            None => Ok(()),
162        }
163    }
164
165    /// Records a successful call.
166    pub fn record_success(&self) {
167        let mut transition = None;
168        let mut g = self.inner.lock().unwrap();
169        match g.state {
170            BreakerState::Closed => g.failures = 0,
171            BreakerState::HalfOpen => {
172                g.probes_inflight = g.probes_inflight.saturating_sub(1);
173                g.probe_successes += 1;
174                if g.probe_successes >= self.cfg.success_threshold {
175                    transition = self.set_state(&mut g, BreakerState::Closed);
176                }
177            }
178            BreakerState::Open => {}
179        }
180        drop(g);
181        self.emit(transition);
182    }
183
184    /// Records a failed call.
185    pub fn record_failure(&self) {
186        let mut transition = None;
187        let mut g = self.inner.lock().unwrap();
188        match g.state {
189            BreakerState::Closed => {
190                g.failures += 1;
191                if g.failures >= self.cfg.failure_threshold {
192                    transition = self.set_state(&mut g, BreakerState::Open);
193                }
194            }
195            BreakerState::HalfOpen => {
196                transition = self.set_state(&mut g, BreakerState::Open);
197            }
198            // OPEN: a late failure from a call started before tripping; ignore.
199            BreakerState::Open => {}
200        }
201        drop(g);
202        self.emit(transition);
203    }
204
205    /// Releases a half-open probe slot without counting it either way (the call was cancelled
206    /// or timed out before the backend answered).
207    pub fn record_abort(&self) {
208        let mut g = self.inner.lock().unwrap();
209        if g.state == BreakerState::HalfOpen {
210            g.probes_inflight = g.probes_inflight.saturating_sub(1);
211        }
212    }
213
214    /// Convenience wrapper for an arbitrary fallible closure.
215    pub fn call<F, R, E>(&self, f: F) -> Result<R, PoolError>
216    where
217        F: FnOnce() -> Result<R, E>,
218        PoolError: From<E>,
219    {
220        self.before_call()?;
221        match f() {
222            Ok(r) => {
223                self.record_success();
224                Ok(r)
225            }
226            Err(e) => {
227                self.record_failure();
228                Err(PoolError::from(e))
229            }
230        }
231    }
232
233    /// Forces the breaker back to `Closed`.
234    pub fn reset(&self) {
235        let mut g = self.inner.lock().unwrap();
236        let transition = self.set_state(&mut g, BreakerState::Closed);
237        drop(g);
238        self.emit(transition);
239    }
240
241    // -- internals (call with the lock held) --------------------------------
242
243    fn refresh(&self, g: &mut Inner) -> Option<(BreakerState, BreakerState)> {
244        if g.state == BreakerState::Open
245            && self.clock.monotonic() - g.opened_at >= self.cfg.recovery_timeout
246        {
247            return self.set_state(g, BreakerState::HalfOpen);
248        }
249        None
250    }
251
252    fn set_state(&self, g: &mut Inner, new: BreakerState) -> Option<(BreakerState, BreakerState)> {
253        let old = g.state;
254        if old == new {
255            return None;
256        }
257        g.state = new;
258        g.failures = 0;
259        g.probes_inflight = 0;
260        g.probe_successes = 0;
261        if new == BreakerState::Open {
262            g.opened_at = self.clock.monotonic();
263        }
264        Some((old, new))
265    }
266
267    fn emit(&self, transition: Option<(BreakerState, BreakerState)>) {
268        // Fired after the lock is released so callbacks may safely re-enter the breaker.
269        if let (Some((old, new)), Some(cb)) = (transition, &self.on_change) {
270            cb(old, new);
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use std::sync::atomic::{AtomicU32, Ordering};
279
280    fn breaker(cfg: BreakerConfig, clock: Arc<ManualClock>) -> CircuitBreaker {
281        CircuitBreaker::new(cfg, Some(clock), None)
282    }
283
284    use crate::core::clock::ManualClock;
285
286    #[test]
287    fn wire_values_and_config() {
288        assert_eq!(BreakerState::Closed.as_str(), "closed");
289        assert_eq!(BreakerState::Open.as_str(), "open");
290        assert_eq!(BreakerState::HalfOpen.as_str(), "half_open");
291        assert_eq!(BreakerState::Open.to_string(), "open");
292        assert_eq!(format!("{:?}", BreakerState::Open), "Open");
293        let c = BreakerConfig::default();
294        assert_eq!(c.failure_threshold, 5);
295        assert_eq!(c.recovery_timeout, 30.0);
296        assert_eq!(c.half_open_max_calls, 1);
297        assert_eq!(c.success_threshold, 1);
298        assert!(c.validate().is_ok());
299        assert!(
300            BreakerConfig {
301                failure_threshold: 0,
302                ..c
303            }
304            .validate()
305            .is_err()
306        );
307        assert!(
308            BreakerConfig {
309                recovery_timeout: -1.0,
310                ..c
311            }
312            .validate()
313            .is_err()
314        );
315        assert_eq!(c, c.clone());
316    }
317
318    #[test]
319    fn trips_after_threshold_and_recovers() {
320        let clock = Arc::new(ManualClock::new(1000.0));
321        let cfg = BreakerConfig {
322            failure_threshold: 2,
323            recovery_timeout: 10.0,
324            half_open_max_calls: 1,
325            success_threshold: 1,
326        };
327        let b = breaker(cfg, clock.clone());
328        assert_eq!(b.state(), BreakerState::Closed);
329        b.before_call().unwrap();
330        b.record_failure();
331        assert_eq!(b.state(), BreakerState::Closed); // one failure, still closed
332        b.before_call().unwrap();
333        b.record_failure(); // second -> trips
334        assert_eq!(b.state(), BreakerState::Open);
335        // Open rejects with retry_after.
336        let err = b.before_call().unwrap_err();
337        assert!(matches!(err, PoolError::CircuitOpen { retry_after } if retry_after > 0.0));
338        // After recovery -> half-open, probe allowed, success closes.
339        clock.advance(10.0);
340        assert_eq!(b.state(), BreakerState::HalfOpen);
341        b.before_call().unwrap();
342        b.record_success();
343        assert_eq!(b.state(), BreakerState::Closed);
344    }
345
346    #[test]
347    fn half_open_limits_probes_and_failure_reopens() {
348        let clock = Arc::new(ManualClock::new(0.0));
349        let changes = Arc::new(AtomicU32::new(0));
350        let changes2 = changes.clone();
351        let cfg = BreakerConfig {
352            failure_threshold: 1,
353            recovery_timeout: 5.0,
354            half_open_max_calls: 1,
355            success_threshold: 2,
356        };
357        let b = CircuitBreaker::new(
358            cfg,
359            Some(clock.clone()),
360            Some(Box::new(move |_, _| {
361                changes2.fetch_add(1, Ordering::SeqCst);
362            })),
363        );
364        b.before_call().unwrap();
365        b.record_failure(); // trip -> open (1 change)
366        clock.advance(5.0);
367        b.before_call().unwrap(); // half-open (2 changes), probe inflight
368        // second concurrent probe rejected with retry_after 0
369        assert!(matches!(
370            b.before_call().unwrap_err(),
371            PoolError::CircuitOpen { retry_after } if retry_after == 0.0
372        ));
373        b.record_failure(); // probe fails -> back to open (3 changes)
374        assert_eq!(b.state(), BreakerState::Open);
375        // need 2 successes to close
376        clock.advance(5.0);
377        b.before_call().unwrap();
378        b.record_success(); // 1/2, stays half-open
379        assert_eq!(b.state(), BreakerState::HalfOpen);
380        b.before_call().unwrap();
381        b.record_success(); // 2/2 -> closed
382        assert_eq!(b.state(), BreakerState::Closed);
383        assert!(changes.load(Ordering::SeqCst) >= 3);
384    }
385
386    #[test]
387    fn abort_releases_probe_without_counting() {
388        let clock = Arc::new(ManualClock::new(0.0));
389        let cfg = BreakerConfig {
390            failure_threshold: 1,
391            recovery_timeout: 1.0,
392            half_open_max_calls: 1,
393            success_threshold: 1,
394        };
395        let b = breaker(cfg, clock.clone());
396        b.before_call().unwrap();
397        b.record_failure();
398        clock.advance(1.0);
399        b.before_call().unwrap(); // half-open, probe inflight
400        b.record_abort(); // release slot, no count
401        // a new probe is allowed again
402        b.before_call().unwrap();
403        b.record_success();
404        assert_eq!(b.state(), BreakerState::Closed);
405        // abort in non-half-open is a no-op
406        b.record_abort();
407    }
408
409    #[test]
410    fn reset_and_late_events_and_call_wrapper() {
411        let clock = Arc::new(ManualClock::new(0.0));
412        let b = breaker(BreakerConfig::default(), clock.clone());
413        // success in closed resets failures
414        b.record_success();
415        // late failure/success in open are ignored
416        let cfg = BreakerConfig {
417            failure_threshold: 1,
418            ..BreakerConfig::default()
419        };
420        let b2 = breaker(cfg, clock.clone());
421        b2.before_call().unwrap();
422        b2.record_failure(); // open
423        b2.record_failure(); // ignored (still open)
424        b2.record_success(); // ignored
425        assert_eq!(b2.state(), BreakerState::Open);
426        b2.reset();
427        assert_eq!(b2.state(), BreakerState::Closed);
428        b2.reset(); // no-op (same state)
429
430        // call() wrapper success + failure
431        let ok: Result<i32, PoolError> = b.call(|| Ok::<_, PoolError>(5));
432        assert_eq!(ok.unwrap(), 5);
433        let err: Result<i32, PoolError> = b.call(|| Err::<i32, _>(PoolError::Timeout("t".into())));
434        assert!(err.is_err());
435    }
436
437    #[test]
438    fn defaults_to_system_clock_when_none() {
439        let b = CircuitBreaker::new(BreakerConfig::default(), None, None);
440        assert_eq!(b.state(), BreakerState::Closed);
441        b.before_call().unwrap();
442        b.record_success();
443    }
444
445    #[test]
446    fn validate_each_threshold_branch() {
447        for bad in [
448            BreakerConfig {
449                failure_threshold: 0,
450                ..Default::default()
451            },
452            BreakerConfig {
453                half_open_max_calls: 0,
454                ..Default::default()
455            },
456            BreakerConfig {
457                success_threshold: 0,
458                ..Default::default()
459            },
460        ] {
461            assert!(bad.validate().is_err());
462        }
463    }
464
465    #[test]
466    fn call_short_circuits_when_open() {
467        let clock = Arc::new(ManualClock::new(0.0));
468        let cfg = BreakerConfig {
469            failure_threshold: 1,
470            recovery_timeout: 100.0,
471            ..BreakerConfig::default()
472        };
473        let b = breaker(cfg, clock);
474        b.before_call().unwrap();
475        b.record_failure();
476        let r: Result<i32, PoolError> = b.call(|| Ok::<_, PoolError>(1));
477        assert!(matches!(r, Err(PoolError::CircuitOpen { .. })));
478    }
479}