use std::sync::Mutex;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy)]
pub struct RetryPolicy {
pub max_retries: u32,
pub base: Duration,
pub max_delay: Duration,
pub jitter: f64,
}
impl RetryPolicy {
#[must_use]
pub const fn default_full_jitter() -> Self {
Self {
max_retries: 3,
base: Duration::from_millis(200),
max_delay: Duration::from_secs(30),
jitter: 1.0,
}
}
#[must_use]
pub fn delay(&self, attempt: u32, rng_0_to_1: f64) -> Duration {
let shift = attempt.min(30); let raw_multiplier = 1u64 << shift;
let scaled = self.base.saturating_mul(
u32::try_from(raw_multiplier.min(u64::from(u32::MAX)))
.unwrap_or(u32::MAX),
);
let capped = scaled.min(self.max_delay);
let jitter_frac = self.jitter.clamp(0.0, 1.0);
let jittered_ns = f64::from(
u32::try_from(capped.as_nanos().min(u128::from(u32::MAX)))
.unwrap_or(u32::MAX),
);
let deterministic_ns = jittered_ns * (1.0 - jitter_frac);
let random_ns =
jittered_ns * jitter_frac * rng_0_to_1.clamp(0.0, 1.0);
Duration::from_nanos((deterministic_ns + random_ns) as u64)
}
}
impl Default for RetryPolicy {
fn default() -> Self {
Self::default_full_jitter()
}
}
pub(crate) fn cheap_random_0_to_1() -> f64 {
let now = Instant::now();
let nanos = now.elapsed().as_nanos() as u64;
let scrambled = nanos.wrapping_mul(0x9E37_79B9_7F4A_7C15);
(scrambled as f64) / (u64::MAX as f64)
}
#[derive(Debug)]
pub struct CircuitBreaker {
budget: u32,
window: Duration,
inner: Mutex<CircuitBreakerState>,
}
#[derive(Debug)]
struct CircuitBreakerState {
tokens: u32,
window_start: Instant,
}
impl CircuitBreaker {
#[must_use]
pub fn new(budget: u32, window: Duration) -> Self {
Self {
budget,
window,
inner: Mutex::new(CircuitBreakerState {
tokens: budget,
window_start: Instant::now(),
}),
}
}
pub fn allow(&self) -> bool {
let mut state = self.inner.lock().unwrap_or_else(|e| {
self.inner.clear_poison();
e.into_inner()
});
if state.window_start.elapsed() >= self.window {
state.tokens = self.budget;
state.window_start = Instant::now();
}
state.tokens > 0
}
pub fn record_failure(&self) {
let mut state = self.inner.lock().unwrap_or_else(|e| {
self.inner.clear_poison();
e.into_inner()
});
if state.window_start.elapsed() >= self.window {
state.tokens = self.budget;
state.window_start = Instant::now();
}
state.tokens = state.tokens.saturating_sub(1);
}
pub fn record_success(&self) {
let mut state = self.inner.lock().unwrap_or_else(|e| {
self.inner.clear_poison();
e.into_inner()
});
if state.tokens < self.budget {
state.tokens += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn delay_zero_attempt_is_base() {
let p = RetryPolicy {
max_retries: 3,
base: Duration::from_millis(100),
max_delay: Duration::from_secs(10),
jitter: 0.0,
};
assert_eq!(p.delay(0, 0.5), Duration::from_millis(100));
}
#[test]
fn delay_doubles_per_attempt_no_jitter() {
let p = RetryPolicy {
max_retries: 3,
base: Duration::from_millis(100),
max_delay: Duration::from_secs(10),
jitter: 0.0,
};
assert_eq!(p.delay(0, 0.0), Duration::from_millis(100));
assert_eq!(p.delay(1, 0.0), Duration::from_millis(200));
assert_eq!(p.delay(2, 0.0), Duration::from_millis(400));
assert_eq!(p.delay(3, 0.0), Duration::from_millis(800));
}
#[test]
fn delay_caps_at_max() {
let p = RetryPolicy {
max_retries: 20,
base: Duration::from_millis(100),
max_delay: Duration::from_secs(1),
jitter: 0.0,
};
assert_eq!(p.delay(20, 0.0), Duration::from_secs(1));
}
#[test]
fn delay_with_full_jitter_stays_within_bound() {
let p = RetryPolicy {
max_retries: 3,
base: Duration::from_millis(100),
max_delay: Duration::from_secs(10),
jitter: 1.0,
};
assert_eq!(p.delay(2, 0.0), Duration::ZERO);
let max = p.delay(2, 1.0);
assert!(max <= Duration::from_millis(400), "got {max:?}");
assert!(max >= Duration::from_millis(399));
}
#[test]
fn delay_high_attempt_does_not_panic() {
let p = RetryPolicy::default_full_jitter();
let _ = p.delay(100, 0.5);
let _ = p.delay(u32::MAX, 0.5);
}
#[test]
fn cheap_random_is_in_range() {
for _ in 0..64 {
let r = cheap_random_0_to_1();
assert!((0.0..=1.0).contains(&r), "out of range: {r}");
}
}
#[test]
fn breaker_closed_when_budget_untouched() {
let cb = CircuitBreaker::new(3, Duration::from_secs(60));
assert!(cb.allow());
}
#[test]
fn breaker_trips_after_budget_exhausted() {
let cb = CircuitBreaker::new(2, Duration::from_secs(60));
cb.record_failure();
assert!(cb.allow());
cb.record_failure();
assert!(!cb.allow());
}
#[test]
fn breaker_resets_after_window() {
let cb = CircuitBreaker::new(1, Duration::from_millis(50));
cb.record_failure();
assert!(!cb.allow());
std::thread::sleep(Duration::from_millis(75));
assert!(cb.allow());
}
#[test]
fn breaker_success_refills_budget() {
let cb = CircuitBreaker::new(2, Duration::from_secs(60));
cb.record_failure();
cb.record_failure();
assert!(!cb.allow());
cb.record_success();
assert!(cb.allow());
}
#[test]
fn breaker_success_capped_at_budget() {
let cb = CircuitBreaker::new(2, Duration::from_secs(60));
for _ in 0..100 {
cb.record_success();
}
assert!(cb.allow());
cb.record_failure();
assert!(cb.allow());
cb.record_failure();
assert!(!cb.allow());
}
#[test]
fn breaker_survives_lock_poison() {
use std::sync::Arc;
use std::thread;
let cb =
Arc::new(CircuitBreaker::new(3, Duration::from_secs(60)));
let cb_clone = cb.clone();
let _ = thread::spawn(move || {
let _guard = cb_clone.inner.lock().unwrap();
panic!("poison the lock");
})
.join();
assert!(cb.allow());
}
}