Skip to main content

vantage_api_pool/resilient/
breaker.rs

1//! Circuit breaker with a growing cooldown.
2//!
3//! `threshold` consecutive failures open the breaker for `cooldown`. When the
4//! cooldown ends, exactly one caller gets through as the half-open probe. A
5//! failed probe re-opens the breaker for twice the previous cooldown, up to
6//! `max_cooldown`; a success closes it and resets the cooldown to its base.
7//! This is the per-API back-off: nothing above the transport schedules its
8//! own.
9//!
10//! The breaker tracks *reachability*, not correctness. Only a `5xx` or a
11//! transport error counts toward opening it; an answer a retry cannot fix
12//! proves the API is up and closes it again (see [`record_reachable`]).
13//!
14//! [`record_reachable`]: CircuitBreaker::record_reachable
15
16use std::sync::{Arc, Mutex};
17use std::time::{Duration, Instant};
18
19/// Whether an attempt may proceed right now.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub(crate) enum Gate {
22    /// `probe` carries the grant's epoch when this grant is the half-open
23    /// probe — the caller must hold a [`ProbeGuard`] for the duration of that
24    /// attempt so the slot is freed no matter how the attempt ends.
25    Allow { probe: Option<u64> },
26    /// Open; `Duration` is how long until the next probe may run.
27    OpenFor(Duration),
28}
29
30/// What a [`ResilientClient`](crate::ResilientClient)'s breaker is doing right
31/// now, for consumers that poll instead of tracking `TransportEvent`s.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum BreakerState {
34    /// Requests go straight through.
35    Closed,
36    /// Requests are rejected (or wait) until `until`.
37    Open { until: Instant },
38    /// The cooldown has elapsed: one caller may take the probe slot.
39    HalfOpen,
40}
41
42#[derive(Default)]
43struct Inner {
44    consecutive_failures: usize,
45    open_until: Option<Instant>,
46    /// The cooldown the NEXT opening will use.
47    cooldown: Duration,
48    /// The epoch of the probe in flight; other callers keep waiting until it
49    /// reports. Every grant gets a fresh epoch so an outcome recorded by a
50    /// caller whose grant has already been superseded cannot be mistaken for
51    /// the current probe's.
52    probing: Option<u64>,
53    /// The epoch the next probe grant will carry.
54    next_epoch: u64,
55}
56
57pub(crate) struct CircuitBreaker {
58    threshold: usize,
59    base_cooldown: Duration,
60    max_cooldown: Duration,
61    inner: Mutex<Inner>,
62}
63
64/// How long a waiter sleeps before re-checking while a probe is in flight.
65const PROBE_POLL: Duration = Duration::from_millis(5);
66
67impl CircuitBreaker {
68    pub(crate) fn new(threshold: usize, base_cooldown: Duration, max_cooldown: Duration) -> Self {
69        Self {
70            threshold: threshold.max(1),
71            base_cooldown,
72            max_cooldown: max_cooldown.max(base_cooldown),
73            inner: Mutex::new(Inner {
74                cooldown: base_cooldown,
75                ..Inner::default()
76            }),
77        }
78    }
79
80    pub(crate) fn gate(&self) -> Gate {
81        let mut s = self.inner.lock().unwrap();
82        match s.open_until {
83            None => Gate::Allow { probe: None },
84            Some(until) => {
85                let now = Instant::now();
86                if now < until {
87                    return Gate::OpenFor(until - now);
88                }
89                if s.probing.is_some() {
90                    return Gate::OpenFor(PROBE_POLL);
91                }
92                let epoch = s.next_epoch;
93                s.next_epoch = s.next_epoch.wrapping_add(1);
94                s.probing = Some(epoch);
95                Gate::Allow { probe: Some(epoch) }
96            }
97        }
98    }
99
100    pub(crate) fn state(&self) -> BreakerState {
101        let s = self.inner.lock().unwrap();
102        match s.open_until {
103            None => BreakerState::Closed,
104            Some(until) if Instant::now() < until => BreakerState::Open { until },
105            Some(_) => BreakerState::HalfOpen,
106        }
107    }
108
109    /// Frees the half-open probe slot held by `epoch`, without touching
110    /// `open_until` or the cooldown. A stale epoch is ignored, so a guard
111    /// dropped after its grant was superseded cannot clear the flag that the
112    /// current probe holder just set — which is what lets
113    /// [`ProbeGuard::drop`] call this unconditionally.
114    pub(crate) fn release_probe(&self, epoch: u64) {
115        let mut s = self.inner.lock().unwrap();
116        if s.probing == Some(epoch) {
117            s.probing = None;
118        }
119    }
120
121    /// A `2xx`: the API works. Closes the breaker and clears the failure run.
122    /// Returns `true` when this closed an open breaker.
123    pub(crate) fn record_success(&self) -> bool {
124        let mut s = self.inner.lock().unwrap();
125        let was_open = s.open_until.is_some();
126        s.consecutive_failures = 0;
127        s.open_until = None;
128        s.probing = None;
129        s.cooldown = self.base_cooldown;
130        was_open
131    }
132
133    /// An answer that proves the API is reachable but says nothing about its
134    /// health — a `4xx` a retry cannot fix, or a `401`. Closes an open
135    /// breaker, releases the probe and resets the cooldown to its base, but
136    /// keeps the failure run: a server alternating `503` and `404` must still
137    /// reach the threshold. Returns `true` when this closed an open breaker.
138    pub(crate) fn record_reachable(&self) -> bool {
139        let mut s = self.inner.lock().unwrap();
140        let was_open = s.open_until.is_some();
141        s.open_until = None;
142        s.probing = None;
143        s.cooldown = self.base_cooldown;
144        was_open
145    }
146
147    /// A `5xx` or a transport error. `probe` is the epoch of the grant this
148    /// failure belongs to, when the attempt held the probe slot: only a
149    /// failure matching the probe in flight doubles the cooldown; anything
150    /// else counts as an ordinary failure.
151    ///
152    /// Returns `Some(cooldown)` when this failure opened (or re-opened) the
153    /// breaker.
154    pub(crate) fn record_failure(&self, probe: Option<u64>) -> Option<Duration> {
155        let mut s = self.inner.lock().unwrap();
156        s.consecutive_failures += 1;
157        if probe.is_some() && probe == s.probing {
158            // The probe failed: stay open, twice as long.
159            s.probing = None;
160            s.cooldown = s.cooldown.saturating_mul(2).min(self.max_cooldown);
161            let cooldown = s.cooldown;
162            s.open_until = Some(deadline(cooldown));
163            return Some(cooldown);
164        }
165        if s.open_until.is_none() && s.consecutive_failures >= self.threshold {
166            let cooldown = s.cooldown;
167            s.open_until = Some(deadline(cooldown));
168            return Some(cooldown);
169        }
170        None
171    }
172}
173
174/// `now + cooldown`, or `now` when that instant is not representable on this
175/// platform's clock — an unrepresentable deadline is already past.
176fn deadline(cooldown: Duration) -> Instant {
177    let now = Instant::now();
178    now.checked_add(cooldown).unwrap_or(now)
179}
180
181/// Holds the half-open probe slot for one attempt. Dropping it — on a
182/// normal return, an early `?`, or the enclosing future being cancelled —
183/// releases the slot, so a probe attempt that never calls `record_success`
184/// or `record_failure` cannot jam the breaker open forever.
185pub(crate) struct ProbeGuard {
186    breaker: Arc<CircuitBreaker>,
187    epoch: u64,
188}
189
190impl ProbeGuard {
191    pub(crate) fn new(breaker: Arc<CircuitBreaker>, epoch: u64) -> Self {
192        Self { breaker, epoch }
193    }
194
195    /// The grant this guard holds, for `record_failure`.
196    pub(crate) fn epoch(&self) -> u64 {
197        self.epoch
198    }
199}
200
201impl Drop for ProbeGuard {
202    fn drop(&mut self) {
203        self.breaker.release_probe(self.epoch);
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    /// Take the probe grant `gate()` just handed out, or panic.
212    fn probe_epoch(gate: Gate) -> u64 {
213        match gate {
214            Gate::Allow { probe: Some(e) } => e,
215            other => panic!("expected a probe grant, got {other:?}"),
216        }
217    }
218
219    #[test]
220    fn opens_after_threshold_and_doubles_on_failed_probe() {
221        let b = CircuitBreaker::new(2, Duration::from_millis(10), Duration::from_millis(25));
222        assert_eq!(b.gate(), Gate::Allow { probe: None });
223        assert_eq!(b.record_failure(None), None);
224        assert_eq!(b.record_failure(None), Some(Duration::from_millis(10)));
225        assert!(matches!(b.gate(), Gate::OpenFor(_)));
226        std::thread::sleep(Duration::from_millis(12));
227        let first = probe_epoch(b.gate());
228        assert_eq!(b.gate(), Gate::OpenFor(PROBE_POLL), "one probe at a time");
229        assert_eq!(
230            b.record_failure(Some(first)),
231            Some(Duration::from_millis(20))
232        );
233        std::thread::sleep(Duration::from_millis(22));
234        let second = probe_epoch(b.gate());
235        assert_ne!(second, first, "each grant gets a fresh epoch");
236        assert_eq!(
237            b.record_failure(Some(second)),
238            Some(Duration::from_millis(25)),
239            "capped"
240        );
241    }
242
243    #[test]
244    fn success_closes_and_resets() {
245        let b = CircuitBreaker::new(1, Duration::from_millis(10), Duration::from_millis(40));
246        assert_eq!(b.record_failure(None), Some(Duration::from_millis(10)));
247        std::thread::sleep(Duration::from_millis(12));
248        let epoch = probe_epoch(b.gate());
249        assert_eq!(
250            b.record_failure(Some(epoch)),
251            Some(Duration::from_millis(20))
252        );
253        std::thread::sleep(Duration::from_millis(22));
254        probe_epoch(b.gate());
255        assert!(b.record_success());
256        assert_eq!(
257            b.record_failure(None),
258            Some(Duration::from_millis(10)),
259            "back to base"
260        );
261    }
262
263    #[test]
264    fn reachable_closes_without_clearing_the_failure_run() {
265        let b = CircuitBreaker::new(3, Duration::from_millis(10), Duration::from_millis(10));
266        // 503, 404, 503, 404, 503 — the 404s prove the server answers, so
267        // they must not reset the run that opens the breaker.
268        assert_eq!(b.record_failure(None), None);
269        assert!(!b.record_reachable(), "nothing was open to close");
270        assert_eq!(b.record_failure(None), None);
271        assert!(!b.record_reachable());
272        assert_eq!(
273            b.record_failure(None),
274            Some(Duration::from_millis(10)),
275            "the third 5xx reaches the threshold"
276        );
277        assert!(b.record_reachable(), "a 4xx closes an open breaker");
278        assert_eq!(b.state(), BreakerState::Closed);
279    }
280
281    #[test]
282    fn state_moves_closed_open_half_open() {
283        let b = CircuitBreaker::new(1, Duration::from_millis(10), Duration::from_millis(10));
284        assert_eq!(b.state(), BreakerState::Closed);
285        b.record_failure(None);
286        assert!(matches!(b.state(), BreakerState::Open { .. }));
287        std::thread::sleep(Duration::from_millis(12));
288        assert_eq!(b.state(), BreakerState::HalfOpen);
289        assert!(b.record_success());
290        assert_eq!(b.state(), BreakerState::Closed);
291    }
292
293    #[test]
294    fn dropped_probe_guard_releases_the_slot() {
295        let b = Arc::new(CircuitBreaker::new(
296            1,
297            Duration::from_millis(10),
298            Duration::from_millis(10),
299        ));
300        assert_eq!(b.record_failure(None), Some(Duration::from_millis(10)));
301        std::thread::sleep(Duration::from_millis(12));
302        let epoch = probe_epoch(b.gate());
303        let guard = ProbeGuard::new(Arc::clone(&b), epoch);
304        assert_eq!(
305            b.gate(),
306            Gate::OpenFor(PROBE_POLL),
307            "the probe is in flight"
308        );
309        drop(guard);
310        assert!(
311            matches!(b.gate(), Gate::Allow { probe: Some(_) }),
312            "dropping the guard without recording an outcome frees the slot"
313        );
314    }
315
316    #[test]
317    fn a_stale_guard_does_not_free_the_current_probe() {
318        let b = Arc::new(CircuitBreaker::new(
319            1,
320            Duration::from_millis(10),
321            Duration::from_millis(10),
322        ));
323        b.record_failure(None);
324        std::thread::sleep(Duration::from_millis(12));
325        let stale = ProbeGuard::new(Arc::clone(&b), probe_epoch(b.gate()));
326        // The stale grant's outcome is recorded, re-opening the breaker, and
327        // a second caller is granted the probe before the guard is dropped.
328        b.record_failure(Some(stale.epoch()));
329        std::thread::sleep(Duration::from_millis(22));
330        let fresh = probe_epoch(b.gate());
331        drop(stale);
332        assert_eq!(
333            b.gate(),
334            Gate::OpenFor(PROBE_POLL),
335            "the fresh grant still holds the slot"
336        );
337        assert!(b.record_failure(Some(fresh)).is_some());
338    }
339
340    #[test]
341    fn a_failure_outside_the_probe_does_not_double_the_cooldown() {
342        let b = Arc::new(CircuitBreaker::new(
343            1,
344            Duration::from_millis(10),
345            Duration::from_millis(80),
346        ));
347        b.record_failure(None);
348        std::thread::sleep(Duration::from_millis(12));
349        let epoch = probe_epoch(b.gate());
350        // A concurrent caller's failure arrives while the probe is in flight.
351        assert_eq!(b.record_failure(None), None, "the breaker is already open");
352        assert_eq!(
353            b.record_failure(Some(epoch)),
354            Some(Duration::from_millis(20)),
355            "only the probe's own failure doubles the cooldown"
356        );
357    }
358}