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}