use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Gate {
Allow { probe: Option<u64> },
OpenFor(Duration),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BreakerState {
Closed,
Open { until: Instant },
HalfOpen,
}
#[derive(Default)]
struct Inner {
consecutive_failures: usize,
open_until: Option<Instant>,
cooldown: Duration,
probing: Option<u64>,
next_epoch: u64,
}
pub(crate) struct CircuitBreaker {
threshold: usize,
base_cooldown: Duration,
max_cooldown: Duration,
inner: Mutex<Inner>,
}
const PROBE_POLL: Duration = Duration::from_millis(5);
impl CircuitBreaker {
pub(crate) fn new(threshold: usize, base_cooldown: Duration, max_cooldown: Duration) -> Self {
Self {
threshold: threshold.max(1),
base_cooldown,
max_cooldown: max_cooldown.max(base_cooldown),
inner: Mutex::new(Inner {
cooldown: base_cooldown,
..Inner::default()
}),
}
}
pub(crate) fn gate(&self) -> Gate {
let mut s = self.inner.lock().unwrap();
match s.open_until {
None => Gate::Allow { probe: None },
Some(until) => {
let now = Instant::now();
if now < until {
return Gate::OpenFor(until - now);
}
if s.probing.is_some() {
return Gate::OpenFor(PROBE_POLL);
}
let epoch = s.next_epoch;
s.next_epoch = s.next_epoch.wrapping_add(1);
s.probing = Some(epoch);
Gate::Allow { probe: Some(epoch) }
}
}
}
pub(crate) fn state(&self) -> BreakerState {
let s = self.inner.lock().unwrap();
match s.open_until {
None => BreakerState::Closed,
Some(until) if Instant::now() < until => BreakerState::Open { until },
Some(_) => BreakerState::HalfOpen,
}
}
pub(crate) fn release_probe(&self, epoch: u64) {
let mut s = self.inner.lock().unwrap();
if s.probing == Some(epoch) {
s.probing = None;
}
}
pub(crate) fn record_success(&self) -> bool {
let mut s = self.inner.lock().unwrap();
let was_open = s.open_until.is_some();
s.consecutive_failures = 0;
s.open_until = None;
s.probing = None;
s.cooldown = self.base_cooldown;
was_open
}
pub(crate) fn record_reachable(&self) -> bool {
let mut s = self.inner.lock().unwrap();
let was_open = s.open_until.is_some();
s.open_until = None;
s.probing = None;
s.cooldown = self.base_cooldown;
was_open
}
pub(crate) fn record_failure(&self, probe: Option<u64>) -> Option<Duration> {
let mut s = self.inner.lock().unwrap();
s.consecutive_failures += 1;
if probe.is_some() && probe == s.probing {
s.probing = None;
s.cooldown = s.cooldown.saturating_mul(2).min(self.max_cooldown);
let cooldown = s.cooldown;
s.open_until = Some(deadline(cooldown));
return Some(cooldown);
}
if s.open_until.is_none() && s.consecutive_failures >= self.threshold {
let cooldown = s.cooldown;
s.open_until = Some(deadline(cooldown));
return Some(cooldown);
}
None
}
}
fn deadline(cooldown: Duration) -> Instant {
let now = Instant::now();
now.checked_add(cooldown).unwrap_or(now)
}
pub(crate) struct ProbeGuard {
breaker: Arc<CircuitBreaker>,
epoch: u64,
}
impl ProbeGuard {
pub(crate) fn new(breaker: Arc<CircuitBreaker>, epoch: u64) -> Self {
Self { breaker, epoch }
}
pub(crate) fn epoch(&self) -> u64 {
self.epoch
}
}
impl Drop for ProbeGuard {
fn drop(&mut self) {
self.breaker.release_probe(self.epoch);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn probe_epoch(gate: Gate) -> u64 {
match gate {
Gate::Allow { probe: Some(e) } => e,
other => panic!("expected a probe grant, got {other:?}"),
}
}
#[test]
fn opens_after_threshold_and_doubles_on_failed_probe() {
let b = CircuitBreaker::new(2, Duration::from_millis(10), Duration::from_millis(25));
assert_eq!(b.gate(), Gate::Allow { probe: None });
assert_eq!(b.record_failure(None), None);
assert_eq!(b.record_failure(None), Some(Duration::from_millis(10)));
assert!(matches!(b.gate(), Gate::OpenFor(_)));
std::thread::sleep(Duration::from_millis(12));
let first = probe_epoch(b.gate());
assert_eq!(b.gate(), Gate::OpenFor(PROBE_POLL), "one probe at a time");
assert_eq!(
b.record_failure(Some(first)),
Some(Duration::from_millis(20))
);
std::thread::sleep(Duration::from_millis(22));
let second = probe_epoch(b.gate());
assert_ne!(second, first, "each grant gets a fresh epoch");
assert_eq!(
b.record_failure(Some(second)),
Some(Duration::from_millis(25)),
"capped"
);
}
#[test]
fn success_closes_and_resets() {
let b = CircuitBreaker::new(1, Duration::from_millis(10), Duration::from_millis(40));
assert_eq!(b.record_failure(None), Some(Duration::from_millis(10)));
std::thread::sleep(Duration::from_millis(12));
let epoch = probe_epoch(b.gate());
assert_eq!(
b.record_failure(Some(epoch)),
Some(Duration::from_millis(20))
);
std::thread::sleep(Duration::from_millis(22));
probe_epoch(b.gate());
assert!(b.record_success());
assert_eq!(
b.record_failure(None),
Some(Duration::from_millis(10)),
"back to base"
);
}
#[test]
fn reachable_closes_without_clearing_the_failure_run() {
let b = CircuitBreaker::new(3, Duration::from_millis(10), Duration::from_millis(10));
assert_eq!(b.record_failure(None), None);
assert!(!b.record_reachable(), "nothing was open to close");
assert_eq!(b.record_failure(None), None);
assert!(!b.record_reachable());
assert_eq!(
b.record_failure(None),
Some(Duration::from_millis(10)),
"the third 5xx reaches the threshold"
);
assert!(b.record_reachable(), "a 4xx closes an open breaker");
assert_eq!(b.state(), BreakerState::Closed);
}
#[test]
fn state_moves_closed_open_half_open() {
let b = CircuitBreaker::new(1, Duration::from_millis(10), Duration::from_millis(10));
assert_eq!(b.state(), BreakerState::Closed);
b.record_failure(None);
assert!(matches!(b.state(), BreakerState::Open { .. }));
std::thread::sleep(Duration::from_millis(12));
assert_eq!(b.state(), BreakerState::HalfOpen);
assert!(b.record_success());
assert_eq!(b.state(), BreakerState::Closed);
}
#[test]
fn dropped_probe_guard_releases_the_slot() {
let b = Arc::new(CircuitBreaker::new(
1,
Duration::from_millis(10),
Duration::from_millis(10),
));
assert_eq!(b.record_failure(None), Some(Duration::from_millis(10)));
std::thread::sleep(Duration::from_millis(12));
let epoch = probe_epoch(b.gate());
let guard = ProbeGuard::new(Arc::clone(&b), epoch);
assert_eq!(
b.gate(),
Gate::OpenFor(PROBE_POLL),
"the probe is in flight"
);
drop(guard);
assert!(
matches!(b.gate(), Gate::Allow { probe: Some(_) }),
"dropping the guard without recording an outcome frees the slot"
);
}
#[test]
fn a_stale_guard_does_not_free_the_current_probe() {
let b = Arc::new(CircuitBreaker::new(
1,
Duration::from_millis(10),
Duration::from_millis(10),
));
b.record_failure(None);
std::thread::sleep(Duration::from_millis(12));
let stale = ProbeGuard::new(Arc::clone(&b), probe_epoch(b.gate()));
b.record_failure(Some(stale.epoch()));
std::thread::sleep(Duration::from_millis(22));
let fresh = probe_epoch(b.gate());
drop(stale);
assert_eq!(
b.gate(),
Gate::OpenFor(PROBE_POLL),
"the fresh grant still holds the slot"
);
assert!(b.record_failure(Some(fresh)).is_some());
}
#[test]
fn a_failure_outside_the_probe_does_not_double_the_cooldown() {
let b = Arc::new(CircuitBreaker::new(
1,
Duration::from_millis(10),
Duration::from_millis(80),
));
b.record_failure(None);
std::thread::sleep(Duration::from_millis(12));
let epoch = probe_epoch(b.gate());
assert_eq!(b.record_failure(None), None, "the breaker is already open");
assert_eq!(
b.record_failure(Some(epoch)),
Some(Duration::from_millis(20)),
"only the probe's own failure doubles the cooldown"
);
}
}