use std::time::Duration;
#[derive(Debug, Clone, PartialEq)]
pub enum Backoff {
Immediate,
Constant {
delay: Duration,
},
Exponential {
initial: Duration,
max: Duration,
multiplier: f64,
},
}
impl Backoff {
pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
match self {
Backoff::Immediate => Duration::ZERO,
Backoff::Constant { delay } => *delay,
Backoff::Exponential {
initial,
max,
multiplier,
} => {
let attempt = attempt.max(1);
let exp = (attempt - 1) as i32;
let scaled = (initial.as_secs_f64()) * multiplier.max(1.0).powi(exp);
let bounded = scaled.min(max.as_secs_f64());
Duration::from_secs_f64(bounded)
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ReconnectPolicy {
pub backoff: Backoff,
pub jitter: f64,
pub max_attempts: Option<u32>,
pub reset_on_success: bool,
}
impl ReconnectPolicy {
pub fn disabled() -> Self {
Self {
backoff: Backoff::Immediate,
jitter: 0.0,
max_attempts: Some(0),
reset_on_success: true,
}
}
pub fn immediate_forever() -> Self {
Self {
backoff: Backoff::Immediate,
jitter: 0.0,
max_attempts: None,
reset_on_success: true,
}
}
pub fn exponential() -> Self {
Self {
backoff: Backoff::Exponential {
initial: Duration::from_millis(250),
max: Duration::from_secs(30),
multiplier: 2.0,
},
jitter: 0.25,
max_attempts: None,
reset_on_success: true,
}
}
pub fn delay_with_jitter(&self, attempt: u32, sample: f64) -> Duration {
let base = self.backoff.delay_for_attempt(attempt);
if self.jitter <= 0.0 || base.is_zero() {
return base;
}
let jitter = self.jitter.clamp(0.0, 1.0);
let factor = 1.0 + (sample.clamp(0.0, 1.0) * 2.0 - 1.0) * jitter;
Duration::from_secs_f64(base.as_secs_f64() * factor.max(0.0))
}
pub fn attempts_exhausted(&self, attempt: u32) -> bool {
self.max_attempts.is_some_and(|cap| attempt > cap)
}
}
impl Default for ReconnectPolicy {
fn default() -> Self {
Self::exponential()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exponential_backoff_caps_at_max() {
let backoff = Backoff::Exponential {
initial: Duration::from_secs(1),
max: Duration::from_secs(10),
multiplier: 2.0,
};
assert_eq!(backoff.delay_for_attempt(1), Duration::from_secs(1));
assert_eq!(backoff.delay_for_attempt(2), Duration::from_secs(2));
assert_eq!(backoff.delay_for_attempt(3), Duration::from_secs(4));
assert_eq!(backoff.delay_for_attempt(4), Duration::from_secs(8));
assert_eq!(backoff.delay_for_attempt(5), Duration::from_secs(10));
assert_eq!(backoff.delay_for_attempt(50), Duration::from_secs(10));
}
#[test]
fn jitter_zero_returns_base_delay() {
let policy = ReconnectPolicy {
backoff: Backoff::Constant {
delay: Duration::from_secs(5),
},
jitter: 0.0,
max_attempts: None,
reset_on_success: true,
};
assert_eq!(policy.delay_with_jitter(1, 0.5), Duration::from_secs(5));
}
#[test]
fn jitter_at_extremes_scales_delay() {
let policy = ReconnectPolicy {
backoff: Backoff::Constant {
delay: Duration::from_secs(10),
},
jitter: 0.5,
max_attempts: None,
reset_on_success: true,
};
assert_eq!(policy.delay_with_jitter(1, 0.0), Duration::from_secs(5));
assert_eq!(policy.delay_with_jitter(1, 1.0), Duration::from_secs(15));
assert_eq!(policy.delay_with_jitter(1, 0.5), Duration::from_secs(10));
}
#[test]
fn disabled_policy_exhausts_immediately() {
let policy = ReconnectPolicy::disabled();
assert!(policy.attempts_exhausted(1));
}
#[test]
fn unbounded_policy_never_exhausts() {
let policy = ReconnectPolicy::exponential();
assert!(!policy.attempts_exhausted(1_000_000));
}
}