use std::time::Duration;
use ulid::Ulid;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Backoff {
base: Duration,
cap: Duration,
}
impl Backoff {
pub fn new(base: Duration, cap: Duration) -> Backoff {
Backoff { base, cap }
}
pub fn base(&self) -> Duration {
self.base
}
pub fn cap(&self) -> Duration {
self.cap
}
}
impl Default for Backoff {
fn default() -> Backoff {
Backoff {
base: Duration::from_millis(500),
cap: Duration::from_secs(120),
}
}
}
pub(crate) fn retry_delay(backoff: &Backoff, fraction: f64, attempt: u32, id: Ulid) -> Duration {
let delay = base_delay(backoff, attempt);
if delay.is_zero() {
return Duration::ZERO;
}
let fraction = fraction.clamp(0.0, 1.0);
let unit = jitter_unit(id, attempt);
let factor = 1.0 - fraction * (1.0 - unit);
let nanos = delay.as_nanos() as f64 * factor;
Duration::from_nanos(nanos as u64)
}
fn base_delay(backoff: &Backoff, attempt: u32) -> Duration {
let multiplier = u128::from(fib_minus_one(attempt));
let base = backoff.base().as_nanos();
let cap = backoff.cap().as_nanos();
let delay = base.saturating_mul(multiplier).min(cap);
Duration::from_nanos(delay.min(u128::from(u64::MAX)) as u64)
}
fn fib_minus_one(n: u32) -> u64 {
if n <= 2 {
return 0;
}
let (mut prev, mut curr) = (1u64, 1u64);
for _ in 3..=n {
let next = prev.saturating_add(curr);
prev = curr;
curr = next;
}
curr.saturating_sub(1)
}
fn jitter_unit(id: Ulid, attempt: u32) -> f64 {
let bytes = id.to_bytes();
let hi = u64::from_be_bytes(bytes[0..8].try_into().expect("8 bytes"));
let lo = u64::from_be_bytes(bytes[8..16].try_into().expect("8 bytes"));
let mixed = splitmix64(hi ^ splitmix64(lo ^ u64::from(attempt)));
(mixed >> 11) as f64 / ((1u64 << 53) as f64)
}
fn splitmix64(seed: u64) -> u64 {
let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
#[cfg(test)]
mod tests {
use super::*;
fn fixed_id() -> Ulid {
Ulid::from_string("01ARZ3NDEKTSV4RRFFQ69G5FAV").expect("valid ULID")
}
#[test]
fn default_backoff_is_500ms_base_and_120s_cap() {
let backoff = Backoff::default();
assert_eq!(backoff.base(), Duration::from_millis(500));
assert_eq!(backoff.cap(), Duration::from_secs(120));
}
#[test]
fn fib_minus_one_matches_the_documented_curve() {
let got: Vec<u64> = (1..=8).map(fib_minus_one).collect();
assert_eq!(got, vec![0, 0, 1, 2, 4, 7, 12, 20]);
}
#[test]
fn first_two_attempts_are_immediate() {
let backoff = Backoff::new(Duration::from_secs(1), Duration::from_secs(300));
assert_eq!(retry_delay(&backoff, 0.5, 1, fixed_id()), Duration::ZERO);
assert_eq!(retry_delay(&backoff, 0.5, 2, fixed_id()), Duration::ZERO);
}
#[test]
fn delay_is_capped() {
let backoff = Backoff::new(Duration::from_secs(1), Duration::from_secs(10));
let delay = retry_delay(&backoff, 0.0, 40, fixed_id());
assert_eq!(delay, Duration::from_secs(10));
}
#[test]
fn jitter_is_deterministic() {
let backoff = Backoff::default();
let a = retry_delay(&backoff, 0.5, 7, fixed_id());
let b = retry_delay(&backoff, 0.5, 7, fixed_id());
assert_eq!(a, b);
}
#[test]
fn jitter_stays_within_bounds() {
let backoff = Backoff::new(Duration::from_secs(1), Duration::from_secs(300));
let fraction = 0.5;
for attempt in 3..=20 {
let base = base_delay(&backoff, attempt);
let actual = retry_delay(&backoff, fraction, attempt, fixed_id());
let low = base.as_nanos() as f64 * (1.0 - fraction);
assert!(
(actual.as_nanos() as f64) >= low - 1.0,
"attempt {attempt}: {actual:?} below lower bound"
);
assert!(
actual <= base,
"attempt {attempt}: {actual:?} above base {base:?}"
);
}
}
proptest::proptest! {
#[test]
fn delay_is_deterministic_and_bounded(
bits in proptest::prelude::any::<u128>(),
attempt in 1u32..80,
fraction in 0.0f64..=1.0,
) {
let backoff = Backoff::new(Duration::from_secs(1), Duration::from_secs(300));
let id = Ulid::from(bits);
let first = retry_delay(&backoff, fraction, attempt, id);
let second = retry_delay(&backoff, fraction, attempt, id);
proptest::prop_assert_eq!(first, second);
let base = base_delay(&backoff, attempt);
let low = base.as_nanos() as f64 * (1.0 - fraction);
proptest::prop_assert!(first <= base);
proptest::prop_assert!((first.as_nanos() as f64) >= low - 1.0);
}
}
}