use std::thread; use std::time::Duration; /// Configuration for retrying an operation with exponential backoff. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct BackoffConfig { /// Total number of attempts, including the first immediate try. pub max_attempts: usize, /// Delay before the first retry. pub initial_delay: Duration, /// Maximum delay between retries. pub max_delay: Duration, /// Factor used to grow the delay after each failed attempt. pub multiplier: u32, } impl Default for BackoffConfig { fn default() -> Self { Self { max_attempts: 3, initial_delay: Duration::from_millis(100), max_delay: Duration::from_secs(5), multiplier: 2, } } } /// Error returned when all retry attempts fail. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RetryError { pub attempts: usize, pub last_error: E, } /// Retries `operation` until it succeeds or the configured attempts are exhausted. /// /// The closure receives the current attempt number, starting at `1`. pub fn retry_with_backoff( config: BackoffConfig, operation: F, ) -> Result> where F: FnMut(usize) -> Result, { retry_with_sleep(config, operation, thread::sleep) } /// Same retry helper as `retry_with_backoff`, but with injectable sleeping. /// /// This is useful for tests or environments that need custom timing behavior. pub fn retry_with_sleep( config: BackoffConfig, mut operation: F, mut sleep: S, ) -> Result> where F: FnMut(usize) -> Result, S: FnMut(Duration), { let max_attempts = config.max_attempts.max(1); let multiplier = config.multiplier.max(1); let mut delay = config.initial_delay; for attempt in 1..=max_attempts { match operation(attempt) { Ok(value) => return Ok(value), Err(error) if attempt == max_attempts => { return Err(RetryError { attempts: attempt, last_error: error, }); } Err(_) => { sleep(delay); delay = delay.saturating_mul(multiplier).min(config.max_delay); } } } unreachable!("max_attempts is forced to be at least one") } #[cfg(test)] mod tests { use super::*; #[test] fn succeeds_after_retrying() { let config = BackoffConfig { max_attempts: 4, initial_delay: Duration::from_millis(10), max_delay: Duration::from_millis(100), multiplier: 2, }; let mut calls = 0; let mut sleeps = Vec::new(); let result = retry_with_sleep( config, |attempt| { calls += 1; if attempt < 3 { Err("not yet") } else { Ok("done") } }, |delay| sleeps.push(delay), ); assert_eq!(result, Ok("done")); assert_eq!(calls, 3); assert_eq!( sleeps, vec![Duration::from_millis(10), Duration::from_millis(20)] ); } #[test] fn returns_last_error_after_exhausting_attempts() { let config = BackoffConfig { max_attempts: 3, initial_delay: Duration::from_millis(5), max_delay: Duration::from_millis(8), multiplier: 3, }; let mut sleeps = Vec::new(); let result = retry_with_sleep( config, |attempt| Err(format!("failed on attempt {attempt}")), |delay| sleeps.push(delay), ); assert_eq!( result, Err(RetryError { attempts: 3, last_error: "failed on attempt 3".to_string(), }) ); assert_eq!( sleeps, vec![Duration::from_millis(5), Duration::from_millis(8)] ); } }