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 = (self.cfg.recovery_timeout
145                    - (self.clock.monotonic() - g.opened_at))
146                    .max(0.0);
147                call_err = Some(PoolError::CircuitOpen { retry_after: wait });
148            }
149            BreakerState::HalfOpen => {
150                if g.probes_inflight >= self.cfg.half_open_max_calls {
151                    call_err = Some(PoolError::CircuitOpen { retry_after: 0.0 });
152                } else {
153                    g.probes_inflight += 1;
154                }
155            }
156            BreakerState::Closed => {}
157        }
158        drop(g);
159        self.emit(transition);
160        match call_err {
161            Some(e) => Err(e),
162            None => Ok(()),
163        }
164    }
165
166    /// Records a successful call.
167    pub fn record_success(&self) {
168        let mut transition = None;
169        let mut g = self.inner.lock().unwrap();
170        match g.state {
171            BreakerState::Closed => g.failures = 0,
172            BreakerState::HalfOpen => {
173                g.probes_inflight = g.probes_inflight.saturating_sub(1);
174                g.probe_successes += 1;
175                if g.probe_successes >= self.cfg.success_threshold {
176                    transition = self.set_state(&mut g, BreakerState::Closed);
177                }
178            }
179            BreakerState::Open => {}
180        }
181        drop(g);
182        self.emit(transition);
183    }
184
185    /// Records a failed call.
186    pub fn record_failure(&self) {
187        let mut transition = None;
188        let mut g = self.inner.lock().unwrap();
189        match g.state {
190            BreakerState::Closed => {
191                g.failures += 1;
192                if g.failures >= self.cfg.failure_threshold {
193                    transition = self.set_state(&mut g, BreakerState::Open);
194                }
195            }
196            BreakerState::HalfOpen => {
197                transition = self.set_state(&mut g, BreakerState::Open);
198            }
199            // OPEN: a late failure from a call started before tripping; ignore.
200            BreakerState::Open => {}
201        }
202        drop(g);
203        self.emit(transition);
204    }
205
206    /// Releases a half-open probe slot without counting it either way (the call was cancelled
207    /// or timed out before the backend answered).
208    pub fn record_abort(&self) {
209        let mut g = self.inner.lock().unwrap();
210        if g.state == BreakerState::HalfOpen {
211            g.probes_inflight = g.probes_inflight.saturating_sub(1);
212        }
213    }
214
215    /// Convenience wrapper for an arbitrary fallible closure.
216    pub fn call<F, R, E>(&self, f: F) -> Result<R, PoolError>
217    where
218        F: FnOnce() -> Result<R, E>,
219        PoolError: From<E>,
220    {
221        self.before_call()?;
222        match f() {
223            Ok(r) => {
224                self.record_success();
225                Ok(r)
226            }
227            Err(e) => {
228                self.record_failure();
229                Err(PoolError::from(e))
230            }
231        }
232    }
233
234    /// Forces the breaker back to `Closed`.
235    pub fn reset(&self) {
236        let mut g = self.inner.lock().unwrap();
237        let transition = self.set_state(&mut g, BreakerState::Closed);
238        drop(g);
239        self.emit(transition);
240    }
241
242    // -- internals (call with the lock held) --------------------------------
243
244    fn refresh(&self, g: &mut Inner) -> Option<(BreakerState, BreakerState)> {
245        if g.state == BreakerState::Open
246            && self.clock.monotonic() - g.opened_at >= self.cfg.recovery_timeout
247        {
248            return self.set_state(g, BreakerState::HalfOpen);
249        }
250        None
251    }
252
253    fn set_state(&self, g: &mut Inner, new: BreakerState) -> Option<(BreakerState, BreakerState)> {
254        let old = g.state;
255        if old == new {
256            return None;
257        }
258        g.state = new;
259        g.failures = 0;
260        g.probes_inflight = 0;
261        g.probe_successes = 0;
262        if new == BreakerState::Open {
263            g.opened_at = self.clock.monotonic();
264        }
265        Some((old, new))
266    }
267
268    fn emit(&self, transition: Option<(BreakerState, BreakerState)>) {
269        // Fired after the lock is released so callbacks may safely re-enter the breaker.
270        if let (Some((old, new)), Some(cb)) = (transition, &self.on_change) {
271            cb(old, new);
272        }
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use std::sync::atomic::{AtomicU32, Ordering};
280
281    fn breaker(cfg: BreakerConfig, clock: Arc<ManualClock>) -> CircuitBreaker {
282        CircuitBreaker::new(cfg, Some(clock), None)
283    }
284
285    use crate::core::clock::ManualClock;
286
287    #[test]
288    fn wire_values_and_config() {
289        assert_eq!(BreakerState::Closed.as_str(), "closed");
290        assert_eq!(BreakerState::Open.as_str(), "open");
291        assert_eq!(BreakerState::HalfOpen.as_str(), "half_open");
292        assert_eq!(BreakerState::Open.to_string(), "open");
293        assert_eq!(format!("{:?}", BreakerState::Open), "Open");
294        let c = BreakerConfig::default();
295        assert_eq!(c.failure_threshold, 5);
296        assert_eq!(c.recovery_timeout, 30.0);
297        assert_eq!(c.half_open_max_calls, 1);
298        assert_eq!(c.success_threshold, 1);
299        assert!(c.validate().is_ok());
300        assert!(
301            BreakerConfig {
302                failure_threshold: 0,
303                ..c
304            }
305            .validate()
306            .is_err()
307        );
308        assert!(
309            BreakerConfig {
310                recovery_timeout: -1.0,
311                ..c
312            }
313            .validate()
314            .is_err()
315        );
316        assert_eq!(c, c.clone());
317    }
318
319    #[test]
320    fn trips_after_threshold_and_recovers() {
321        let clock = Arc::new(ManualClock::new(1000.0));
322        let cfg = BreakerConfig {
323            failure_threshold: 2,
324            recovery_timeout: 10.0,
325            half_open_max_calls: 1,
326            success_threshold: 1,
327        };
328        let b = breaker(cfg, clock.clone());
329        assert_eq!(b.state(), BreakerState::Closed);
330        b.before_call().unwrap();
331        b.record_failure();
332        assert_eq!(b.state(), BreakerState::Closed); // one failure, still closed
333        b.before_call().unwrap();
334        b.record_failure(); // second -> trips
335        assert_eq!(b.state(), BreakerState::Open);
336        // Open rejects with retry_after.
337        let err = b.before_call().unwrap_err();
338        assert!(matches!(err, PoolError::CircuitOpen { retry_after } if retry_after > 0.0));
339        // After recovery -> half-open, probe allowed, success closes.
340        clock.advance(10.0);
341        assert_eq!(b.state(), BreakerState::HalfOpen);
342        b.before_call().unwrap();
343        b.record_success();
344        assert_eq!(b.state(), BreakerState::Closed);
345    }
346
347    #[test]
348    fn half_open_limits_probes_and_failure_reopens() {
349        let clock = Arc::new(ManualClock::new(0.0));
350        let changes = Arc::new(AtomicU32::new(0));
351        let changes2 = changes.clone();
352        let cfg = BreakerConfig {
353            failure_threshold: 1,
354            recovery_timeout: 5.0,
355            half_open_max_calls: 1,
356            success_threshold: 2,
357        };
358        let b = CircuitBreaker::new(
359            cfg,
360            Some(clock.clone()),
361            Some(Box::new(move |_, _| {
362                changes2.fetch_add(1, Ordering::SeqCst);
363            })),
364        );
365        b.before_call().unwrap();
366        b.record_failure(); // trip -> open (1 change)
367        clock.advance(5.0);
368        b.before_call().unwrap(); // half-open (2 changes), probe inflight
369        // second concurrent probe rejected with retry_after 0
370        assert!(matches!(
371            b.before_call().unwrap_err(),
372            PoolError::CircuitOpen { retry_after } if retry_after == 0.0
373        ));
374        b.record_failure(); // probe fails -> back to open (3 changes)
375        assert_eq!(b.state(), BreakerState::Open);
376        // need 2 successes to close
377        clock.advance(5.0);
378        b.before_call().unwrap();
379        b.record_success(); // 1/2, stays half-open
380        assert_eq!(b.state(), BreakerState::HalfOpen);
381        b.before_call().unwrap();
382        b.record_success(); // 2/2 -> closed
383        assert_eq!(b.state(), BreakerState::Closed);
384        assert!(changes.load(Ordering::SeqCst) >= 3);
385    }
386
387    #[test]
388    fn abort_releases_probe_without_counting() {
389        let clock = Arc::new(ManualClock::new(0.0));
390        let cfg = BreakerConfig {
391            failure_threshold: 1,
392            recovery_timeout: 1.0,
393            half_open_max_calls: 1,
394            success_threshold: 1,
395        };
396        let b = breaker(cfg, clock.clone());
397        b.before_call().unwrap();
398        b.record_failure();
399        clock.advance(1.0);
400        b.before_call().unwrap(); // half-open, probe inflight
401        b.record_abort(); // release slot, no count
402        // a new probe is allowed again
403        b.before_call().unwrap();
404        b.record_success();
405        assert_eq!(b.state(), BreakerState::Closed);
406        // abort in non-half-open is a no-op
407        b.record_abort();
408    }
409
410    #[test]
411    fn reset_and_late_events_and_call_wrapper() {
412        let clock = Arc::new(ManualClock::new(0.0));
413        let b = breaker(BreakerConfig::default(), clock.clone());
414        // success in closed resets failures
415        b.record_success();
416        // late failure/success in open are ignored
417        let cfg = BreakerConfig {
418            failure_threshold: 1,
419            ..BreakerConfig::default()
420        };
421        let b2 = breaker(cfg, clock.clone());
422        b2.before_call().unwrap();
423        b2.record_failure(); // open
424        b2.record_failure(); // ignored (still open)
425        b2.record_success(); // ignored
426        assert_eq!(b2.state(), BreakerState::Open);
427        b2.reset();
428        assert_eq!(b2.state(), BreakerState::Closed);
429        b2.reset(); // no-op (same state)
430
431        // call() wrapper success + failure
432        let ok: Result<i32, PoolError> = b.call(|| Ok::<_, PoolError>(5));
433        assert_eq!(ok.unwrap(), 5);
434        let err: Result<i32, PoolError> =
435            b.call(|| Err::<i32, _>(PoolError::Timeout("t".into())));
436        assert!(err.is_err());
437    }
438
439    #[test]
440    fn defaults_to_system_clock_when_none() {
441        let b = CircuitBreaker::new(BreakerConfig::default(), None, None);
442        assert_eq!(b.state(), BreakerState::Closed);
443        b.before_call().unwrap();
444        b.record_success();
445    }
446
447    #[test]
448    fn validate_each_threshold_branch() {
449        for bad in [
450            BreakerConfig { failure_threshold: 0, ..Default::default() },
451            BreakerConfig { half_open_max_calls: 0, ..Default::default() },
452            BreakerConfig { success_threshold: 0, ..Default::default() },
453        ] {
454            assert!(bad.validate().is_err());
455        }
456    }
457
458    #[test]
459    fn call_short_circuits_when_open() {
460        let clock = Arc::new(ManualClock::new(0.0));
461        let cfg = BreakerConfig {
462            failure_threshold: 1,
463            recovery_timeout: 100.0,
464            ..BreakerConfig::default()
465        };
466        let b = breaker(cfg, clock);
467        b.before_call().unwrap();
468        b.record_failure();
469        let r: Result<i32, PoolError> = b.call(|| Ok::<_, PoolError>(1));
470        assert!(matches!(r, Err(PoolError::CircuitOpen { .. })));
471    }
472}