Skip to main content

reliakit_circuit/
lib.rs

1//! Clock-agnostic circuit breaker.
2//!
3//! A circuit breaker protects a caller from a failing dependency: once failures
4//! pile up it "opens" and rejects calls immediately (failing fast) instead of
5//! hammering a service that is already down, then periodically lets a trial call
6//! through to test recovery.
7//!
8//! [`CircuitBreaker`] is a small, `Copy` state machine. It does **not** read the
9//! clock, sleep, or allocate — you pass the current time in on each call as a
10//! plain `u64` in whatever monotonic unit you choose (milliseconds is typical).
11//! That keeps it usable from synchronous code, any async runtime, and `no_std`
12//! / embedded targets, and makes its behavior fully deterministic in tests.
13//!
14//! # States
15//!
16//! ```text
17//!            failures >= failure_threshold
18//!   Closed ───────────────────────────────▶ Open
19//!     ▲                                       │
20//!     │ successes >= success_threshold        │ cooldown elapsed
21//!     │                                       ▼
22//!     └────────────── HalfOpen ◀──────────────┘
23//!                        │
24//!                        │ any failure
25//!                        └──────────────▶ Open
26//! ```
27//!
28//! - **Closed** — calls flow normally. Consecutive failures are counted; once
29//!   they reach `failure_threshold` the breaker trips to **Open**.
30//! - **Open** — calls are rejected immediately. After `cooldown` time units the
31//!   next [`allow`](CircuitBreaker::allow) moves it to **HalfOpen**.
32//! - **HalfOpen** — trial calls are allowed. `success_threshold` consecutive
33//!   successes close the breaker; the first failure reopens it.
34//!
35//! # Example
36//!
37//! ```
38//! use reliakit_circuit::{CircuitBreaker, State};
39//!
40//! // Trip after 3 consecutive failures; stay open for 30_000 ms.
41//! let mut cb = CircuitBreaker::new(3, 30_000);
42//!
43//! // A run of failures opens the breaker.
44//! for _ in 0..3 {
45//!     assert!(cb.allow(0));      // still Closed, calls allowed
46//!     cb.on_failure(0);
47//! }
48//! assert_eq!(cb.state(), State::Open);
49//! assert!(!cb.allow(1_000));     // rejected while Open (cooldown not elapsed)
50//!
51//! // After the cooldown, one trial call is allowed (HalfOpen).
52//! assert!(cb.allow(31_000));
53//! assert_eq!(cb.state(), State::HalfOpen);
54//!
55//! // A success closes it again.
56//! cb.on_success();
57//! assert_eq!(cb.state(), State::Closed);
58//! ```
59//!
60//! # Counting failures by rate
61//!
62//! [`CircuitBreaker`] counts *consecutive* failures. For a *failure rate* over a
63//! rolling window — "trip if N of the last M calls failed" — use
64//! [`RollingBreaker`], a const-generic, inline (zero-allocation) variant.
65//!
66//! # Feature flags
67//!
68//! - `core` (off by default) adds `*_now(clock)` convenience methods on
69//!   [`CircuitBreaker`] and [`RollingBreaker`] that read the time from a
70//!   `reliakit_core::Clock`. It pulls in `reliakit-core` (`no_std`, zero
71//!   third-party dependencies); the `now: u64` methods remain the primitive API.
72
73#![no_std]
74#![forbid(unsafe_code)]
75#![warn(missing_docs)]
76
77mod rolling;
78
79pub use rolling::RollingBreaker;
80
81/// The state of a [`CircuitBreaker`].
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83pub enum State {
84    /// Calls flow normally; failures are being counted.
85    Closed,
86    /// Calls are rejected immediately until the cooldown elapses.
87    Open,
88    /// A trial period: limited calls are allowed to test recovery.
89    HalfOpen,
90}
91
92/// A circuit breaker: a small, `Copy` state machine that decides whether calls
93/// to a dependency should be allowed, based on their recent success/failure
94/// history and a caller-supplied clock.
95///
96/// Time is a plain `u64` in any monotonic unit you choose (commonly
97/// milliseconds); `cooldown` uses the same unit. The breaker never reads the
98/// clock itself — pass `now` to [`allow`](Self::allow) and
99/// [`on_failure`](Self::on_failure).
100///
101/// `CircuitBreaker` is not internally synchronized. Share one across threads by
102/// wrapping it in your own `Mutex`/lock.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub struct CircuitBreaker {
105    failure_threshold: u32,
106    success_threshold: u32,
107    cooldown: u64,
108    state: State,
109    failures: u32,
110    successes: u32,
111    opened_at: u64,
112}
113
114impl CircuitBreaker {
115    /// Creates a breaker that trips to [`State::Open`] after `failure_threshold`
116    /// consecutive failures and stays open for `cooldown` time units.
117    ///
118    /// The success threshold defaults to `1` (a single trial success closes the
119    /// breaker); change it with [`with_success_threshold`](Self::with_success_threshold).
120    /// A `failure_threshold` of `0` is treated as `1`.
121    pub const fn new(failure_threshold: u32, cooldown: u64) -> Self {
122        Self {
123            failure_threshold: if failure_threshold == 0 {
124                1
125            } else {
126                failure_threshold
127            },
128            success_threshold: 1,
129            cooldown,
130            state: State::Closed,
131            failures: 0,
132            successes: 0,
133            opened_at: 0,
134        }
135    }
136
137    /// Sets how many consecutive successes in [`State::HalfOpen`] are required to
138    /// close the breaker. A value of `0` is treated as `1`.
139    pub const fn with_success_threshold(mut self, success_threshold: u32) -> Self {
140        self.success_threshold = if success_threshold == 0 {
141            1
142        } else {
143            success_threshold
144        };
145        self
146    }
147
148    /// Returns the current state without advancing time.
149    ///
150    /// Note that a breaker which has been [`State::Open`] past its cooldown still
151    /// reports `Open` here until the next [`allow`](Self::allow) call moves it to
152    /// [`State::HalfOpen`].
153    pub const fn state(&self) -> State {
154        self.state
155    }
156
157    /// Returns the configured failure threshold.
158    pub const fn failure_threshold(&self) -> u32 {
159        self.failure_threshold
160    }
161
162    /// Returns the configured success threshold.
163    pub const fn success_threshold(&self) -> u32 {
164        self.success_threshold
165    }
166
167    /// Returns the configured cooldown, in the caller's time unit.
168    pub const fn cooldown(&self) -> u64 {
169        self.cooldown
170    }
171
172    /// Returns whether a call may proceed at `now`.
173    ///
174    /// If the breaker is [`State::Open`] and `cooldown` time units have elapsed
175    /// since it opened, this transitions it to [`State::HalfOpen`] and returns
176    /// `true` to permit a trial call. Otherwise it returns `true` for
177    /// `Closed`/`HalfOpen` and `false` for `Open`.
178    ///
179    /// `now` is expected to be monotonic non-decreasing; a clock that moves
180    /// backwards is handled with saturating arithmetic (it simply keeps the
181    /// breaker open) and never panics.
182    pub fn allow(&mut self, now: u64) -> bool {
183        if matches!(self.state, State::Open) && now.saturating_sub(self.opened_at) >= self.cooldown
184        {
185            self.state = State::HalfOpen;
186            self.successes = 0;
187        }
188        !matches!(self.state, State::Open)
189    }
190
191    /// Records that an allowed call succeeded.
192    ///
193    /// In [`State::Closed`] this resets the consecutive-failure count. In
194    /// [`State::HalfOpen`] it counts toward `success_threshold`, closing the
195    /// breaker once reached. Has no effect while [`State::Open`].
196    pub fn on_success(&mut self) {
197        match self.state {
198            State::Closed => self.failures = 0,
199            State::HalfOpen => {
200                self.successes = self.successes.saturating_add(1);
201                if self.successes >= self.success_threshold {
202                    self.state = State::Closed;
203                    self.failures = 0;
204                    self.successes = 0;
205                }
206            }
207            State::Open => {}
208        }
209    }
210
211    /// Records that an allowed call failed, at time `now`.
212    ///
213    /// In [`State::Closed`] this counts toward `failure_threshold`, tripping the
214    /// breaker to [`State::Open`] once reached. In [`State::HalfOpen`] any
215    /// failure reopens the breaker. Has no effect while [`State::Open`].
216    pub fn on_failure(&mut self, now: u64) {
217        match self.state {
218            State::Closed => {
219                self.failures = self.failures.saturating_add(1);
220                if self.failures >= self.failure_threshold {
221                    self.trip(now);
222                }
223            }
224            State::HalfOpen => self.trip(now),
225            State::Open => {}
226        }
227    }
228
229    /// Forces the breaker [`State::Open`] as of `now` (e.g. on a fatal signal).
230    pub fn trip(&mut self, now: u64) {
231        self.state = State::Open;
232        self.opened_at = now;
233        self.failures = 0;
234        self.successes = 0;
235    }
236
237    /// Forces the breaker back to [`State::Closed`] and clears its counters.
238    pub fn reset(&mut self) {
239        self.state = State::Closed;
240        self.failures = 0;
241        self.successes = 0;
242        self.opened_at = 0;
243    }
244}
245
246/// Convenience methods that read the current time from a
247/// [`Clock`](reliakit_core::Clock) instead of taking an explicit `now: u64`.
248///
249/// Available with the `core` feature. Each forwards to the matching `now`-taking
250/// method, which remains the primitive API.
251#[cfg(feature = "core")]
252impl CircuitBreaker {
253    /// Like [`allow`](Self::allow), reading the time from `clock`.
254    ///
255    /// ```
256    /// use reliakit_circuit::CircuitBreaker;
257    /// use reliakit_core::ManualClock;
258    ///
259    /// let clock = ManualClock::new(0);
260    /// let mut breaker = CircuitBreaker::new(1, 1_000);
261    /// breaker.on_failure_now(&clock); // one failure trips it
262    /// assert!(!breaker.allow_now(&clock)); // still cooling down
263    /// clock.set(1_000);
264    /// assert!(breaker.allow_now(&clock)); // cooldown elapsed -> half-open trial
265    /// ```
266    pub fn allow_now<C: reliakit_core::Clock>(&mut self, clock: &C) -> bool {
267        self.allow(clock.now())
268    }
269
270    /// Like [`on_failure`](Self::on_failure), reading the time from `clock`.
271    pub fn on_failure_now<C: reliakit_core::Clock>(&mut self, clock: &C) {
272        self.on_failure(clock.now())
273    }
274
275    /// Like [`trip`](Self::trip), reading the time from `clock`.
276    pub fn trip_now<C: reliakit_core::Clock>(&mut self, clock: &C) {
277        self.trip(clock.now())
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn starts_closed_and_allows() {
287        let mut cb = CircuitBreaker::new(3, 1000);
288        assert_eq!(cb.state(), State::Closed);
289        assert!(cb.allow(0));
290    }
291
292    #[test]
293    fn failures_below_threshold_stay_closed() {
294        let mut cb = CircuitBreaker::new(3, 1000);
295        cb.on_failure(0);
296        cb.on_failure(0);
297        assert_eq!(cb.state(), State::Closed);
298        assert!(cb.allow(0));
299    }
300
301    #[test]
302    fn reaching_threshold_opens_and_rejects() {
303        let mut cb = CircuitBreaker::new(3, 1000);
304        for _ in 0..3 {
305            cb.on_failure(0);
306        }
307        assert_eq!(cb.state(), State::Open);
308        assert!(!cb.allow(500)); // cooldown not elapsed
309    }
310
311    #[test]
312    fn success_resets_failure_run_in_closed() {
313        let mut cb = CircuitBreaker::new(3, 1000);
314        cb.on_failure(0);
315        cb.on_failure(0);
316        cb.on_success();
317        cb.on_failure(0);
318        cb.on_failure(0);
319        assert_eq!(cb.state(), State::Closed); // run was interrupted
320        cb.on_failure(0);
321        assert_eq!(cb.state(), State::Open);
322    }
323
324    #[test]
325    fn open_transitions_to_half_open_after_cooldown() {
326        let mut cb = CircuitBreaker::new(1, 1000);
327        cb.on_failure(0);
328        assert_eq!(cb.state(), State::Open);
329        assert!(!cb.allow(999)); // 1ms short
330        assert_eq!(cb.state(), State::Open);
331        assert!(cb.allow(1000)); // exactly cooldown -> HalfOpen
332        assert_eq!(cb.state(), State::HalfOpen);
333    }
334
335    #[test]
336    fn half_open_success_closes() {
337        let mut cb = CircuitBreaker::new(1, 1000);
338        cb.on_failure(0);
339        assert!(cb.allow(1000));
340        assert_eq!(cb.state(), State::HalfOpen);
341        cb.on_success();
342        assert_eq!(cb.state(), State::Closed);
343    }
344
345    #[test]
346    fn half_open_failure_reopens_with_new_cooldown() {
347        let mut cb = CircuitBreaker::new(1, 1000);
348        cb.on_failure(0);
349        assert!(cb.allow(1000));
350        assert_eq!(cb.state(), State::HalfOpen);
351        cb.on_failure(1000);
352        assert_eq!(cb.state(), State::Open);
353        assert!(!cb.allow(1999)); // cooldown counts from the reopen at t=1000
354        assert!(cb.allow(2000));
355        assert_eq!(cb.state(), State::HalfOpen);
356    }
357
358    #[test]
359    fn success_threshold_requires_multiple_successes() {
360        let mut cb = CircuitBreaker::new(1, 1000).with_success_threshold(2);
361        cb.on_failure(0);
362        assert!(cb.allow(1000));
363        cb.on_success();
364        assert_eq!(cb.state(), State::HalfOpen); // 1 of 2
365        cb.on_success();
366        assert_eq!(cb.state(), State::Closed); // 2 of 2
367    }
368
369    #[test]
370    fn cooldown_zero_allows_immediately() {
371        let mut cb = CircuitBreaker::new(1, 0);
372        cb.on_failure(0);
373        assert_eq!(cb.state(), State::Open);
374        assert!(cb.allow(0)); // 0 elapsed >= 0 cooldown
375        assert_eq!(cb.state(), State::HalfOpen);
376    }
377
378    #[test]
379    fn zero_failure_threshold_is_treated_as_one() {
380        let mut cb = CircuitBreaker::new(0, 1000);
381        assert_eq!(cb.failure_threshold(), 1);
382        cb.on_failure(0);
383        assert_eq!(cb.state(), State::Open);
384    }
385
386    #[test]
387    fn backwards_clock_does_not_panic_or_close_early() {
388        let mut cb = CircuitBreaker::new(1, 1000);
389        cb.on_failure(10_000);
390        // now < opened_at: saturating_sub -> 0, which is < cooldown, stays Open.
391        assert!(!cb.allow(5_000));
392        assert_eq!(cb.state(), State::Open);
393    }
394
395    #[test]
396    fn trip_and_reset_are_explicit() {
397        let mut cb = CircuitBreaker::new(5, 1000);
398        cb.trip(0);
399        assert_eq!(cb.state(), State::Open);
400        cb.reset();
401        assert_eq!(cb.state(), State::Closed);
402        assert!(cb.allow(0));
403    }
404
405    #[test]
406    fn on_outcome_while_open_is_ignored() {
407        let mut cb = CircuitBreaker::new(1, 1000);
408        cb.on_failure(0);
409        let before = cb;
410        cb.on_success();
411        cb.on_failure(0);
412        assert_eq!(cb, before); // no state change while Open
413    }
414}
415
416#[cfg(all(test, feature = "core"))]
417mod core_tests {
418    use super::*;
419    use reliakit_core::ManualClock;
420
421    #[test]
422    fn now_methods_match_explicit_now() {
423        let clock = ManualClock::new(0);
424        let mut viaclock = CircuitBreaker::new(2, 1_000);
425        let mut explicit = CircuitBreaker::new(2, 1_000);
426
427        viaclock.on_failure_now(&clock);
428        explicit.on_failure(0);
429        assert_eq!(viaclock, explicit);
430
431        viaclock.on_failure_now(&clock); // trips
432        explicit.on_failure(0);
433        assert_eq!(viaclock, explicit);
434        assert_eq!(viaclock.state(), State::Open);
435
436        assert_eq!(viaclock.allow_now(&clock), explicit.allow(0)); // both false
437        clock.set(1_000);
438        assert_eq!(viaclock.allow_now(&clock), explicit.allow(1_000)); // both true
439        assert_eq!(viaclock, explicit);
440    }
441
442    #[test]
443    fn trip_now_matches_trip() {
444        let clock = ManualClock::new(500);
445        let mut viaclock = CircuitBreaker::new(3, 100);
446        let mut explicit = CircuitBreaker::new(3, 100);
447        viaclock.trip_now(&clock);
448        explicit.trip(500);
449        assert_eq!(viaclock, explicit);
450        assert_eq!(viaclock.state(), State::Open);
451    }
452}