#![doc = include_str!("../README.md")]
#![deny(missing_docs)]
#![deny(rustdoc::broken_intra_doc_links)]
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Jitter {
None,
Equal,
Full,
}
#[derive(Debug, Clone, Copy)]
pub struct Backoff {
pub initial: Duration,
pub multiplier: f64,
pub max: Duration,
pub jitter: Jitter,
}
impl Default for Backoff {
fn default() -> Self {
Self {
initial: Duration::from_secs(1),
multiplier: 2.0,
max: Duration::from_secs(3600),
jitter: Jitter::Full,
}
}
}
impl Backoff {
pub fn new(initial: Duration, multiplier: f64, max: Duration, jitter: Jitter) -> Self {
Self {
initial,
multiplier,
max,
jitter,
}
}
pub fn smtp_outbound() -> Self {
Self {
initial: Duration::from_secs(60),
multiplier: 2.5,
max: Duration::from_secs(8 * 3600),
jitter: Jitter::Full,
}
}
pub fn auth_lockout() -> Self {
Self {
initial: Duration::from_secs(30 * 60),
multiplier: 2.0,
max: Duration::from_secs(24 * 3600),
jitter: Jitter::None,
}
}
pub fn webhook() -> Self {
Self {
initial: Duration::from_secs(60),
multiplier: 2.0,
max: Duration::from_secs(6 * 3600),
jitter: Jitter::Equal,
}
}
pub fn base_delay(&self, attempt: u32) -> Duration {
if attempt > 64 && self.multiplier >= 1.0 {
return self.max;
}
let initial_ns = self.initial.as_nanos() as f64;
let max_ns = self.max.as_nanos() as f64;
let factor = self.multiplier.powi(attempt as i32);
let raw = initial_ns * factor;
let clamped = raw.min(max_ns).max(0.0);
Duration::from_nanos(clamped as u64)
}
pub fn delay(&self, attempt: u32, seed: u64) -> Duration {
let base = self.base_delay(attempt);
match self.jitter {
Jitter::None => base,
Jitter::Equal => {
let half_ns = base.as_nanos() as u64 / 2;
let random_part = scale_random(seed, half_ns);
Duration::from_nanos(half_ns + random_part)
}
Jitter::Full => {
let base_ns = base.as_nanos() as u64;
let random = scale_random(seed, base_ns);
Duration::from_nanos(random)
}
}
}
pub fn should_give_up(attempt: u32, max_attempts: u32) -> bool {
attempt >= max_attempts
}
}
#[inline]
fn scale_random(seed: u64, ceiling: u64) -> u64 {
if ceiling == 0 {
return 0;
}
let mut x = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
x ^= x >> 30;
x = x.wrapping_mul(0xBF58_476D_1CE4_E5B9);
x ^= x >> 27;
x = x.wrapping_mul(0x94D0_49BB_1331_11EB);
x ^= x >> 31;
x % ceiling
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_construction() {
let b = Backoff::default();
assert_eq!(b.initial, Duration::from_secs(1));
assert_eq!(b.multiplier, 2.0);
assert_eq!(b.max, Duration::from_secs(3600));
assert_eq!(b.jitter, Jitter::Full);
}
#[test]
fn base_delay_grows_exponentially() {
let b = Backoff {
initial: Duration::from_secs(1),
multiplier: 2.0,
max: Duration::from_secs(3600),
jitter: Jitter::None,
};
assert_eq!(b.base_delay(0), Duration::from_secs(1));
assert_eq!(b.base_delay(1), Duration::from_secs(2));
assert_eq!(b.base_delay(2), Duration::from_secs(4));
assert_eq!(b.base_delay(3), Duration::from_secs(8));
assert_eq!(b.base_delay(10), Duration::from_secs(1024));
}
#[test]
fn base_delay_caps_at_max() {
let b = Backoff {
initial: Duration::from_secs(60),
multiplier: 2.0,
max: Duration::from_secs(3600),
jitter: Jitter::None,
};
assert_eq!(b.base_delay(7), Duration::from_secs(3600));
assert_eq!(b.base_delay(100), Duration::from_secs(3600));
assert_eq!(b.base_delay(u32::MAX), Duration::from_secs(3600));
}
#[test]
fn jitter_none_is_deterministic() {
let b = Backoff {
initial: Duration::from_secs(60),
multiplier: 2.0,
max: Duration::from_secs(3600),
jitter: Jitter::None,
};
assert_eq!(b.delay(3, 0), b.delay(3, 999_999));
assert_eq!(b.delay(3, 0), b.base_delay(3));
}
#[test]
fn jitter_equal_returns_at_least_half_base() {
let b = Backoff {
initial: Duration::from_secs(100),
multiplier: 2.0,
max: Duration::from_secs(10_000),
jitter: Jitter::Equal,
};
let base = b.base_delay(2);
for seed in 0..100u64 {
let d = b.delay(2, seed);
assert!(d >= base / 2, "seed {seed}: d={d:?} >= base/2 {:?}", base / 2);
assert!(d <= base, "seed {seed}: d={d:?} <= base {base:?}");
}
}
#[test]
fn jitter_full_returns_in_zero_to_base() {
let b = Backoff {
initial: Duration::from_secs(100),
multiplier: 2.0,
max: Duration::from_secs(10_000),
jitter: Jitter::Full,
};
let base = b.base_delay(2);
for seed in 0..100u64 {
let d = b.delay(2, seed);
assert!(d < base, "seed {seed}: d={d:?} < base {base:?}");
}
}
#[test]
fn jitter_deterministic_with_same_seed() {
let b = Backoff::smtp_outbound();
assert_eq!(b.delay(3, 42), b.delay(3, 42));
assert_eq!(b.delay(0, 12345), b.delay(0, 12345));
}
#[test]
fn smtp_outbound_preset() {
let b = Backoff::smtp_outbound();
assert_eq!(b.initial, Duration::from_secs(60));
assert_eq!(b.multiplier, 2.5);
assert_eq!(b.max, Duration::from_secs(8 * 3600));
assert_eq!(b.jitter, Jitter::Full);
}
#[test]
fn auth_lockout_preset() {
let b = Backoff::auth_lockout();
assert_eq!(b.initial, Duration::from_secs(30 * 60));
assert_eq!(b.multiplier, 2.0);
assert_eq!(b.max, Duration::from_secs(24 * 3600));
assert_eq!(b.jitter, Jitter::None);
}
#[test]
fn webhook_preset() {
let b = Backoff::webhook();
assert_eq!(b.initial, Duration::from_secs(60));
assert_eq!(b.multiplier, 2.0);
assert_eq!(b.max, Duration::from_secs(6 * 3600));
assert_eq!(b.jitter, Jitter::Equal);
}
#[test]
fn should_give_up_at_max() {
assert!(Backoff::should_give_up(5, 5));
assert!(Backoff::should_give_up(10, 5));
assert!(!Backoff::should_give_up(0, 5));
assert!(!Backoff::should_give_up(4, 5));
}
#[test]
fn should_give_up_zero_max_immediate() {
assert!(Backoff::should_give_up(0, 0));
}
#[test]
fn should_give_up_max_at_u32_boundary() {
assert!(!Backoff::should_give_up(u32::MAX - 1, u32::MAX));
assert!(Backoff::should_give_up(u32::MAX, u32::MAX));
}
#[test]
fn base_delay_attempt_zero_is_initial() {
let b = Backoff::smtp_outbound();
assert_eq!(b.base_delay(0), b.initial);
}
#[test]
fn multiplier_below_one_decays() {
let b = Backoff {
initial: Duration::from_secs(100),
multiplier: 0.5,
max: Duration::from_secs(3600),
jitter: Jitter::None,
};
assert_eq!(b.base_delay(0), Duration::from_secs(100));
assert_eq!(b.base_delay(1), Duration::from_secs(50));
assert_eq!(b.base_delay(2), Duration::from_secs(25));
}
#[test]
fn jitter_full_with_zero_base_returns_zero() {
let b = Backoff {
initial: Duration::ZERO,
multiplier: 2.0,
max: Duration::from_secs(3600),
jitter: Jitter::Full,
};
assert_eq!(b.delay(5, 42), Duration::ZERO);
}
#[test]
fn scale_random_zero_ceiling_returns_zero() {
assert_eq!(scale_random(123, 0), 0);
assert_eq!(scale_random(u64::MAX, 0), 0);
}
#[test]
fn scale_random_distribution_spread() {
let buckets = 10;
let mut hits = [false; 10];
for seed in 0..1000u64 {
let v = scale_random(seed, buckets);
hits[v as usize] = true;
}
let coverage = hits.iter().filter(|h| **h).count();
assert!(coverage >= 9, "only hit {coverage}/{buckets} buckets");
}
#[test]
fn delay_caps_under_jitter() {
let b = Backoff::smtp_outbound();
for attempt in 0..30u32 {
for seed in [0u64, 1, 42, 100, 12345, u64::MAX] {
let d = b.delay(attempt, seed);
assert!(d <= b.max, "attempt={attempt} seed={seed} d={d:?} > max={:?}", b.max);
}
}
}
#[test]
fn very_high_attempt_doesnt_overflow() {
let b = Backoff::smtp_outbound();
let d = b.delay(100, 42);
assert!(d <= b.max);
}
#[test]
fn clone_and_copy_work() {
let a = Backoff::webhook();
let b = a;
assert_eq!(a.initial, b.initial);
assert_eq!(a.multiplier, b.multiplier);
let c = a;
assert_eq!(c.max, a.max);
}
}