use std::thread; use std::time::Duration; /// Settings for retrying an operation with exponential backoff. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct BackoffConfig { /// Number of retries after the first attempt. pub max_retries: usize, /// Delay before the first retry. pub initial_delay: Duration, /// Maximum delay between retries. pub max_delay: Duration, /// Multiplier applied after each failed retry delay. pub multiplier: u32, } impl Default for BackoffConfig { fn default() -> Self { Self { max_retries: 3, initial_delay: Duration::from_millis(100), max_delay: Duration::from_secs(2), multiplier: 2, } } } /// Runs `operation` until it succeeds or the retry budget is exhausted. /// /// `max_retries` counts retries, not total attempts. For example, `max_retries: /// 3` allows up to 4 calls to `operation`. pub fn retry_with_backoff(operation: F, config: BackoffConfig) -> Result where F: FnMut() -> Result, { retry_with_backoff_using(operation, config, thread::sleep) } /// Same retry helper as `retry_with_backoff`, but with an injectable sleeper. /// /// This is useful in tests or in hosts that want to log, mock, or customize /// waiting behavior. pub fn retry_with_backoff_using( mut operation: F, config: BackoffConfig, mut sleep: S, ) -> Result where F: FnMut() -> Result, S: FnMut(Duration), { let multiplier = config.multiplier.max(1); let mut delay = config.initial_delay.min(config.max_delay); for retry in 0..=config.max_retries { match operation() { Ok(value) => return Ok(value), Err(error) if retry == config.max_retries => return Err(error), Err(_) => { sleep(delay); delay = delay.saturating_mul(multiplier).min(config.max_delay); } } } unreachable!("retry loop always returns from success or final failure") } #[cfg(test)] mod tests { use super::*; #[test] fn succeeds_after_retrying() { let mut attempts = 0; let mut sleeps = Vec::new(); let result: Result<&str, &str> = retry_with_backoff_using( || { attempts += 1; if attempts < 3 { Err("not yet") } else { Ok("done") } }, BackoffConfig { max_retries: 5, initial_delay: Duration::from_millis(10), max_delay: Duration::from_millis(100), multiplier: 2, }, |delay| sleeps.push(delay), ); assert_eq!(result, Ok("done")); assert_eq!(attempts, 3); assert_eq!( sleeps, vec![Duration::from_millis(10), Duration::from_millis(20)] ); } #[test] fn returns_last_error_after_retry_budget_is_exhausted() { let mut attempts = 0; let mut sleeps = Vec::new(); let result: Result<(), usize> = retry_with_backoff_using( || { attempts += 1; Err(attempts) }, BackoffConfig { max_retries: 2, initial_delay: Duration::from_millis(5), max_delay: Duration::from_millis(8), multiplier: 3, }, |delay| sleeps.push(delay), ); assert_eq!(result, Err(3)); assert_eq!(attempts, 3); assert_eq!( sleeps, vec![Duration::from_millis(5), Duration::from_millis(8)] ); } }