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}