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}