use std::sync::Mutex;
use std::time::Duration;
use osproxy_core::Instant;
#[derive(Debug, Default)]
struct State {
consecutive_failures: u32,
opened_at: Option<Instant>,
}
#[derive(Debug, Default)]
pub(crate) struct Breaker {
state: Mutex<State>,
}
impl Breaker {
pub(crate) fn allows(&self, now: Instant, cooldown: Duration) -> bool {
let state = self.lock();
match state.opened_at {
None => true,
Some(opened) => now.saturating_duration_since(opened) >= cooldown,
}
}
pub(crate) fn record_success(&self) {
let mut state = self.lock();
state.consecutive_failures = 0;
state.opened_at = None;
}
pub(crate) fn record_failure(&self, now: Instant, threshold: u32) {
let mut state = self.lock();
state.consecutive_failures = state.consecutive_failures.saturating_add(1);
if state.consecutive_failures >= threshold {
state.opened_at = Some(now);
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, State> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
#[cfg(test)]
mod tests {
use super::*;
use osproxy_core::{Clock, ManualClock};
const THRESHOLD: u32 = 2;
const COOLDOWN: Duration = Duration::from_secs(5);
#[test]
fn opens_after_threshold_then_recovers_after_cooldown() {
let clock = ManualClock::new();
let breaker = Breaker::default();
assert!(breaker.allows(clock.now(), COOLDOWN));
breaker.record_failure(clock.now(), THRESHOLD);
assert!(
breaker.allows(clock.now(), COOLDOWN),
"one failure must not open"
);
breaker.record_failure(clock.now(), THRESHOLD);
assert!(
!breaker.allows(clock.now(), COOLDOWN),
"must open at threshold"
);
clock.advance(Duration::from_secs(4));
assert!(!breaker.allows(clock.now(), COOLDOWN));
clock.advance(Duration::from_secs(2));
assert!(
breaker.allows(clock.now(), COOLDOWN),
"half-open trial allowed"
);
breaker.record_success();
assert!(
breaker.allows(clock.now(), COOLDOWN),
"success closes the breaker"
);
}
#[test]
fn a_failed_trial_reopens_and_restarts_the_cooldown() {
let clock = ManualClock::new();
let breaker = Breaker::default();
breaker.record_failure(clock.now(), THRESHOLD);
breaker.record_failure(clock.now(), THRESHOLD); clock.advance(Duration::from_secs(6));
assert!(breaker.allows(clock.now(), COOLDOWN)); breaker.record_failure(clock.now(), THRESHOLD); assert!(
!breaker.allows(clock.now(), COOLDOWN),
"failed trial re-opens"
);
clock.advance(Duration::from_secs(4));
assert!(
!breaker.allows(clock.now(), COOLDOWN),
"cooldown restarted from t=6"
);
}
}