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#![no_std]
67#![forbid(unsafe_code)]
68#![warn(missing_docs)]
69
70mod rolling;
71
72pub use rolling::RollingBreaker;
73
74/// The state of a [`CircuitBreaker`].
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
76pub enum State {
77    /// Calls flow normally; failures are being counted.
78    Closed,
79    /// Calls are rejected immediately until the cooldown elapses.
80    Open,
81    /// A trial period: limited calls are allowed to test recovery.
82    HalfOpen,
83}
84
85/// A circuit breaker: a small, `Copy` state machine that decides whether calls
86/// to a dependency should be allowed, based on their recent success/failure
87/// history and a caller-supplied clock.
88///
89/// Time is a plain `u64` in any monotonic unit you choose (commonly
90/// milliseconds); `cooldown` uses the same unit. The breaker never reads the
91/// clock itself — pass `now` to [`allow`](Self::allow) and
92/// [`on_failure`](Self::on_failure).
93///
94/// `CircuitBreaker` is not internally synchronized. Share one across threads by
95/// wrapping it in your own `Mutex`/lock.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub struct CircuitBreaker {
98    failure_threshold: u32,
99    success_threshold: u32,
100    cooldown: u64,
101    state: State,
102    failures: u32,
103    successes: u32,
104    opened_at: u64,
105}
106
107impl CircuitBreaker {
108    /// Creates a breaker that trips to [`State::Open`] after `failure_threshold`
109    /// consecutive failures and stays open for `cooldown` time units.
110    ///
111    /// The success threshold defaults to `1` (a single trial success closes the
112    /// breaker); change it with [`with_success_threshold`](Self::with_success_threshold).
113    /// A `failure_threshold` of `0` is treated as `1`.
114    pub const fn new(failure_threshold: u32, cooldown: u64) -> Self {
115        Self {
116            failure_threshold: if failure_threshold == 0 {
117                1
118            } else {
119                failure_threshold
120            },
121            success_threshold: 1,
122            cooldown,
123            state: State::Closed,
124            failures: 0,
125            successes: 0,
126            opened_at: 0,
127        }
128    }
129
130    /// Sets how many consecutive successes in [`State::HalfOpen`] are required to
131    /// close the breaker. A value of `0` is treated as `1`.
132    pub const fn with_success_threshold(mut self, success_threshold: u32) -> Self {
133        self.success_threshold = if success_threshold == 0 {
134            1
135        } else {
136            success_threshold
137        };
138        self
139    }
140
141    /// Returns the current state without advancing time.
142    ///
143    /// Note that a breaker which has been [`State::Open`] past its cooldown still
144    /// reports `Open` here until the next [`allow`](Self::allow) call moves it to
145    /// [`State::HalfOpen`].
146    pub const fn state(&self) -> State {
147        self.state
148    }
149
150    /// Returns the configured failure threshold.
151    pub const fn failure_threshold(&self) -> u32 {
152        self.failure_threshold
153    }
154
155    /// Returns the configured success threshold.
156    pub const fn success_threshold(&self) -> u32 {
157        self.success_threshold
158    }
159
160    /// Returns the configured cooldown, in the caller's time unit.
161    pub const fn cooldown(&self) -> u64 {
162        self.cooldown
163    }
164
165    /// Returns whether a call may proceed at `now`.
166    ///
167    /// If the breaker is [`State::Open`] and `cooldown` time units have elapsed
168    /// since it opened, this transitions it to [`State::HalfOpen`] and returns
169    /// `true` to permit a trial call. Otherwise it returns `true` for
170    /// `Closed`/`HalfOpen` and `false` for `Open`.
171    ///
172    /// `now` is expected to be monotonic non-decreasing; a clock that moves
173    /// backwards is handled with saturating arithmetic (it simply keeps the
174    /// breaker open) and never panics.
175    pub fn allow(&mut self, now: u64) -> bool {
176        if matches!(self.state, State::Open) && now.saturating_sub(self.opened_at) >= self.cooldown
177        {
178            self.state = State::HalfOpen;
179            self.successes = 0;
180        }
181        !matches!(self.state, State::Open)
182    }
183
184    /// Records that an allowed call succeeded.
185    ///
186    /// In [`State::Closed`] this resets the consecutive-failure count. In
187    /// [`State::HalfOpen`] it counts toward `success_threshold`, closing the
188    /// breaker once reached. Has no effect while [`State::Open`].
189    pub fn on_success(&mut self) {
190        match self.state {
191            State::Closed => self.failures = 0,
192            State::HalfOpen => {
193                self.successes = self.successes.saturating_add(1);
194                if self.successes >= self.success_threshold {
195                    self.state = State::Closed;
196                    self.failures = 0;
197                    self.successes = 0;
198                }
199            }
200            State::Open => {}
201        }
202    }
203
204    /// Records that an allowed call failed, at time `now`.
205    ///
206    /// In [`State::Closed`] this counts toward `failure_threshold`, tripping the
207    /// breaker to [`State::Open`] once reached. In [`State::HalfOpen`] any
208    /// failure reopens the breaker. Has no effect while [`State::Open`].
209    pub fn on_failure(&mut self, now: u64) {
210        match self.state {
211            State::Closed => {
212                self.failures = self.failures.saturating_add(1);
213                if self.failures >= self.failure_threshold {
214                    self.trip(now);
215                }
216            }
217            State::HalfOpen => self.trip(now),
218            State::Open => {}
219        }
220    }
221
222    /// Forces the breaker [`State::Open`] as of `now` (e.g. on a fatal signal).
223    pub fn trip(&mut self, now: u64) {
224        self.state = State::Open;
225        self.opened_at = now;
226        self.failures = 0;
227        self.successes = 0;
228    }
229
230    /// Forces the breaker back to [`State::Closed`] and clears its counters.
231    pub fn reset(&mut self) {
232        self.state = State::Closed;
233        self.failures = 0;
234        self.successes = 0;
235        self.opened_at = 0;
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn starts_closed_and_allows() {
245        let mut cb = CircuitBreaker::new(3, 1000);
246        assert_eq!(cb.state(), State::Closed);
247        assert!(cb.allow(0));
248    }
249
250    #[test]
251    fn failures_below_threshold_stay_closed() {
252        let mut cb = CircuitBreaker::new(3, 1000);
253        cb.on_failure(0);
254        cb.on_failure(0);
255        assert_eq!(cb.state(), State::Closed);
256        assert!(cb.allow(0));
257    }
258
259    #[test]
260    fn reaching_threshold_opens_and_rejects() {
261        let mut cb = CircuitBreaker::new(3, 1000);
262        for _ in 0..3 {
263            cb.on_failure(0);
264        }
265        assert_eq!(cb.state(), State::Open);
266        assert!(!cb.allow(500)); // cooldown not elapsed
267    }
268
269    #[test]
270    fn success_resets_failure_run_in_closed() {
271        let mut cb = CircuitBreaker::new(3, 1000);
272        cb.on_failure(0);
273        cb.on_failure(0);
274        cb.on_success();
275        cb.on_failure(0);
276        cb.on_failure(0);
277        assert_eq!(cb.state(), State::Closed); // run was interrupted
278        cb.on_failure(0);
279        assert_eq!(cb.state(), State::Open);
280    }
281
282    #[test]
283    fn open_transitions_to_half_open_after_cooldown() {
284        let mut cb = CircuitBreaker::new(1, 1000);
285        cb.on_failure(0);
286        assert_eq!(cb.state(), State::Open);
287        assert!(!cb.allow(999)); // 1ms short
288        assert_eq!(cb.state(), State::Open);
289        assert!(cb.allow(1000)); // exactly cooldown -> HalfOpen
290        assert_eq!(cb.state(), State::HalfOpen);
291    }
292
293    #[test]
294    fn half_open_success_closes() {
295        let mut cb = CircuitBreaker::new(1, 1000);
296        cb.on_failure(0);
297        assert!(cb.allow(1000));
298        assert_eq!(cb.state(), State::HalfOpen);
299        cb.on_success();
300        assert_eq!(cb.state(), State::Closed);
301    }
302
303    #[test]
304    fn half_open_failure_reopens_with_new_cooldown() {
305        let mut cb = CircuitBreaker::new(1, 1000);
306        cb.on_failure(0);
307        assert!(cb.allow(1000));
308        assert_eq!(cb.state(), State::HalfOpen);
309        cb.on_failure(1000);
310        assert_eq!(cb.state(), State::Open);
311        assert!(!cb.allow(1999)); // cooldown counts from the reopen at t=1000
312        assert!(cb.allow(2000));
313        assert_eq!(cb.state(), State::HalfOpen);
314    }
315
316    #[test]
317    fn success_threshold_requires_multiple_successes() {
318        let mut cb = CircuitBreaker::new(1, 1000).with_success_threshold(2);
319        cb.on_failure(0);
320        assert!(cb.allow(1000));
321        cb.on_success();
322        assert_eq!(cb.state(), State::HalfOpen); // 1 of 2
323        cb.on_success();
324        assert_eq!(cb.state(), State::Closed); // 2 of 2
325    }
326
327    #[test]
328    fn cooldown_zero_allows_immediately() {
329        let mut cb = CircuitBreaker::new(1, 0);
330        cb.on_failure(0);
331        assert_eq!(cb.state(), State::Open);
332        assert!(cb.allow(0)); // 0 elapsed >= 0 cooldown
333        assert_eq!(cb.state(), State::HalfOpen);
334    }
335
336    #[test]
337    fn zero_failure_threshold_is_treated_as_one() {
338        let mut cb = CircuitBreaker::new(0, 1000);
339        assert_eq!(cb.failure_threshold(), 1);
340        cb.on_failure(0);
341        assert_eq!(cb.state(), State::Open);
342    }
343
344    #[test]
345    fn backwards_clock_does_not_panic_or_close_early() {
346        let mut cb = CircuitBreaker::new(1, 1000);
347        cb.on_failure(10_000);
348        // now < opened_at: saturating_sub -> 0, which is < cooldown, stays Open.
349        assert!(!cb.allow(5_000));
350        assert_eq!(cb.state(), State::Open);
351    }
352
353    #[test]
354    fn trip_and_reset_are_explicit() {
355        let mut cb = CircuitBreaker::new(5, 1000);
356        cb.trip(0);
357        assert_eq!(cb.state(), State::Open);
358        cb.reset();
359        assert_eq!(cb.state(), State::Closed);
360        assert!(cb.allow(0));
361    }
362
363    #[test]
364    fn on_outcome_while_open_is_ignored() {
365        let mut cb = CircuitBreaker::new(1, 1000);
366        cb.on_failure(0);
367        let before = cb;
368        cb.on_success();
369        cb.on_failure(0);
370        assert_eq!(cb, before); // no state change while Open
371    }
372}