use std::time::Duration;
use rand::Rng;
#[derive(Debug, Clone, Copy)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub base_delay: Duration,
pub max_delay: Duration,
pub jitter: f64,
pub coordinator_timeout: Duration,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_attempts: 5,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(5),
jitter: 0.3,
coordinator_timeout: Duration::from_secs(30),
}
}
}
impl RetryPolicy {
pub fn none() -> Self {
Self {
max_attempts: 1,
coordinator_timeout: Duration::ZERO,
..Self::default()
}
}
pub fn delay(&self, attempt: u32) -> Duration {
if attempt <= 1 {
return Duration::ZERO;
}
let exponent = attempt.saturating_sub(2).min(16);
let scaled = self
.base_delay
.saturating_mul(2u32.saturating_pow(exponent))
.min(self.max_delay);
self.apply_jitter(scaled)
}
fn apply_jitter(&self, delay: Duration) -> Duration {
let jitter = self.jitter.clamp(0.0, 1.0);
if jitter == 0.0 {
return delay;
}
let factor = 1.0 - rand::rng().random_range(0.0..jitter);
delay.mul_f64(factor)
}
pub fn should_retry(&self, attempt: u32) -> bool {
attempt < self.max_attempts
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_first_attempt_never_waits() {
assert_eq!(RetryPolicy::default().delay(0), Duration::ZERO);
assert_eq!(RetryPolicy::default().delay(1), Duration::ZERO);
}
#[test]
fn backoff_grows_and_then_stops_growing() {
let policy = RetryPolicy {
jitter: 0.0,
..RetryPolicy::default()
};
assert_eq!(policy.delay(2), Duration::from_millis(100));
assert_eq!(policy.delay(3), Duration::from_millis(200));
assert_eq!(policy.delay(4), Duration::from_millis(400));
assert_eq!(policy.delay(20), policy.max_delay);
assert_eq!(policy.delay(u32::MAX), policy.max_delay);
}
#[test]
fn the_attempt_budget_is_far_shorter_than_a_coordinator_election() {
let policy = RetryPolicy {
jitter: 0.0,
..RetryPolicy::default()
};
let spent: Duration = (1..=policy.max_attempts).map(|a| policy.delay(a)).sum();
assert_eq!(spent, Duration::from_millis(1_500));
assert!(
policy.coordinator_timeout > spent * 10,
"coordinator errors need a budget of a different order, not a bigger attempt count"
);
}
#[test]
fn none_zeroes_the_coordinator_deadline_too() {
assert_eq!(RetryPolicy::none().coordinator_timeout, Duration::ZERO);
assert!(!RetryPolicy::none().should_retry(1));
}
#[test]
fn jitter_only_shortens_so_the_cap_stays_a_cap() {
let policy = RetryPolicy::default();
for attempt in 2..12 {
for _ in 0..50 {
let delay = policy.delay(attempt);
assert!(delay <= policy.max_delay, "{delay:?}");
}
}
}
#[test]
fn jitter_actually_varies() {
let policy = RetryPolicy {
base_delay: Duration::from_secs(1),
..RetryPolicy::default()
};
let samples: Vec<Duration> = (0..20).map(|_| policy.delay(3)).collect();
assert!(
samples.windows(2).any(|w| w[0] != w[1]),
"a synchronised pool is the failure this exists to prevent"
);
}
#[test]
fn attempts_are_bounded() {
let policy = RetryPolicy::default();
assert!(policy.should_retry(1));
assert!(!policy.should_retry(policy.max_attempts));
assert!(!RetryPolicy::none().should_retry(1));
}
}