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