use std::thread; use std::time::Duration; /// Settings for retrying an operation with exponential backoff. /// /// `max_attempts` includes the first try. For example, `max_attempts = 3` /// means one initial attempt plus up to two retries. #[derive(Debug, Clone, Copy)] pub struct BackoffConfig { pub max_attempts: usize, pub initial_delay: Duration, pub max_delay: Duration, pub multiplier: f64, } impl BackoffConfig { pub fn new(max_attempts: usize, initial_delay: Duration) -> Self { Self { max_attempts, initial_delay, max_delay: Duration::from_secs(30), multiplier: 2.0, } } fn normalized(self) -> Self { Self { max_attempts: self.max_attempts.max(1), initial_delay: self.initial_delay, max_delay: self.max_delay.max(self.initial_delay), multiplier: self.multiplier.max(1.0), } } } impl Default for BackoffConfig { fn default() -> Self { Self::new(3, Duration::from_millis(100)) } } /// Runs `operation` until it succeeds or attempts are exhausted. /// /// The closure receives a zero-based attempt number. The final error is returned /// unchanged, making this helper easy to use with existing error types. pub fn retry(config: BackoffConfig, operation: F) -> Result where F: FnMut(usize) -> Result, { retry_with_sleep(config, operation, thread::sleep) } fn retry_with_sleep( config: BackoffConfig, mut operation: F, mut sleep: S, ) -> Result where F: FnMut(usize) -> Result, S: FnMut(Duration), { let config = config.normalized(); let mut delay = config.initial_delay; for attempt in 0..config.max_attempts { match operation(attempt) { Ok(value) => return Ok(value), Err(error) if attempt + 1 == config.max_attempts => return Err(error), Err(_) => { sleep(delay); delay = next_delay(delay, config.multiplier, config.max_delay); } } } unreachable!("max_attempts is normalized to at least one") } fn next_delay(current: Duration, multiplier: f64, max_delay: Duration) -> Duration { let next = current.mul_f64(multiplier); next.min(max_delay) } #[cfg(test)] mod tests { use super::*; #[test] fn succeeds_after_retrying() { let config = BackoffConfig::new(3, Duration::from_millis(10)); let mut calls = 0; let result = retry_with_sleep( config, |_| { calls += 1; if calls < 3 { Err("not yet") } else { Ok("done") } }, |_| {}, ); assert_eq!(result, Ok("done")); assert_eq!(calls, 3); } #[test] fn uses_exponential_backoff_capped_at_max_delay() { let config = BackoffConfig { max_attempts: 4, initial_delay: Duration::from_millis(10), max_delay: Duration::from_millis(25), multiplier: 2.0, }; let mut sleeps = Vec::new(); let result: Result<(), &str> = retry_with_sleep(config, |_| Err("failed"), |delay| sleeps.push(delay)); assert_eq!(result, Err("failed")); assert_eq!( sleeps, vec![ Duration::from_millis(10), Duration::from_millis(20), Duration::from_millis(25), ] ); } }