use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BackoffConfigError {
BaseExceedsMax {
base: Duration,
max: Duration,
},
}
impl std::fmt::Display for BackoffConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BackoffConfigError::BaseExceedsMax { base, max } => {
write!(f, "base delay ({base:?}) must not exceed max delay ({max:?})")
}
}
}
}
impl std::error::Error for BackoffConfigError {}
pub trait BackoffStrategy: Send + Sync {
fn delay_for_attempt(&self, attempt_number: u32) -> Duration;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FixedBackoff {
interval: Duration,
}
impl FixedBackoff {
pub fn new(interval: Duration) -> Self {
Self { interval }
}
pub fn interval(&self) -> Duration {
self.interval
}
}
impl BackoffStrategy for FixedBackoff {
fn delay_for_attempt(&self, _attempt_number: u32) -> Duration {
self.interval
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExponentialBackoff {
base: Duration,
max: Duration,
}
impl ExponentialBackoff {
pub fn new(base: Duration, max: Duration) -> Result<Self, BackoffConfigError> {
if base > max {
return Err(BackoffConfigError::BaseExceedsMax { base, max });
}
Ok(Self { base, max })
}
pub fn base(&self) -> Duration {
self.base
}
pub fn max(&self) -> Duration {
self.max
}
}
impl BackoffStrategy for ExponentialBackoff {
fn delay_for_attempt(&self, attempt_number: u32) -> Duration {
debug_assert!(attempt_number >= 1, "attempt_number is 1-indexed");
let exponent = attempt_number.saturating_sub(1);
let multiplier = 1u64.checked_shl(exponent).unwrap_or(u64::MAX);
let base_millis = u64::try_from(self.base.as_millis()).unwrap_or(u64::MAX);
let delay_millis = base_millis.saturating_mul(multiplier);
let delay = Duration::from_millis(delay_millis);
delay.min(self.max)
}
}
pub fn retry_ready_at(
retry_wait_entered_at: u64,
attempt_number: u32,
strategy: &dyn BackoffStrategy,
) -> u64 {
let delay = strategy.delay_for_attempt(attempt_number);
let delay_secs = delay.as_secs().saturating_add(u64::from(delay.subsec_nanos() > 0));
retry_wait_entered_at.saturating_add(delay_secs)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fixed_backoff_returns_constant_delay() {
let strategy = FixedBackoff::new(Duration::from_secs(5));
assert_eq!(strategy.delay_for_attempt(1), Duration::from_secs(5));
assert_eq!(strategy.delay_for_attempt(2), Duration::from_secs(5));
assert_eq!(strategy.delay_for_attempt(100), Duration::from_secs(5));
}
#[test]
fn exponential_backoff_doubles_per_attempt() {
let strategy =
ExponentialBackoff::new(Duration::from_secs(1), Duration::from_secs(3600)).unwrap();
assert_eq!(strategy.delay_for_attempt(1), Duration::from_secs(1)); assert_eq!(strategy.delay_for_attempt(2), Duration::from_secs(2)); assert_eq!(strategy.delay_for_attempt(3), Duration::from_secs(4)); assert_eq!(strategy.delay_for_attempt(4), Duration::from_secs(8)); }
#[test]
fn exponential_backoff_caps_at_max() {
let strategy =
ExponentialBackoff::new(Duration::from_secs(1), Duration::from_secs(10)).unwrap();
assert_eq!(strategy.delay_for_attempt(1), Duration::from_secs(1));
assert_eq!(strategy.delay_for_attempt(4), Duration::from_secs(8));
assert_eq!(strategy.delay_for_attempt(5), Duration::from_secs(10)); assert_eq!(strategy.delay_for_attempt(100), Duration::from_secs(10)); }
#[test]
fn exponential_backoff_saturates_on_overflow() {
let strategy =
ExponentialBackoff::new(Duration::from_secs(1000), Duration::from_millis(u64::MAX))
.unwrap();
let delay = strategy.delay_for_attempt(u32::MAX);
assert!(delay.as_millis() > 0);
}
#[test]
fn retry_ready_at_adds_delay_to_current_time() {
let strategy = FixedBackoff::new(Duration::from_secs(30));
assert_eq!(retry_ready_at(1000, 1, &strategy), 1030);
assert_eq!(retry_ready_at(1000, 5, &strategy), 1030);
}
#[test]
fn retry_ready_at_saturates_at_u64_max() {
let strategy = FixedBackoff::new(Duration::from_secs(100));
assert_eq!(retry_ready_at(u64::MAX - 10, 1, &strategy), u64::MAX);
}
#[test]
fn retry_ready_at_rounds_up_sub_second_delay() {
let strategy = FixedBackoff::new(Duration::from_millis(500));
assert_eq!(retry_ready_at(1000, 1, &strategy), 1001);
}
#[test]
fn retry_ready_at_exact_seconds_not_rounded_up() {
let strategy = FixedBackoff::new(Duration::from_secs(3));
assert_eq!(retry_ready_at(1000, 1, &strategy), 1003);
}
#[test]
fn retry_ready_at_exponential_produces_increasing_delays() {
let strategy =
ExponentialBackoff::new(Duration::from_secs(10), Duration::from_secs(3600)).unwrap();
let t1 = retry_ready_at(1000, 1, &strategy);
let t2 = retry_ready_at(1000, 2, &strategy);
let t3 = retry_ready_at(1000, 3, &strategy);
assert_eq!(t1, 1010); assert_eq!(t2, 1020); assert_eq!(t3, 1040); assert!(t1 < t2);
assert!(t2 < t3);
}
}