use std::thread; use std::time::Duration; /// Settings for retrying a fallible operation with exponential backoff. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct RetryConfig { /// Total attempts, including the first call. pub max_attempts: usize, /// Delay before the first retry. pub initial_delay: Duration, /// Upper bound for any delay. pub max_delay: Duration, /// Multiplier applied after each failed retry. pub multiplier: u32, } impl Default for RetryConfig { fn default() -> Self { Self { max_attempts: 3, initial_delay: Duration::from_millis(100), max_delay: Duration::from_secs(5), multiplier: 2, } } } /// Runs `operation` until it succeeds or the configured attempt limit is reached. /// /// The closure receives the 1-based attempt number. The final error from /// `operation` is returned if every attempt fails. pub fn retry_with_exponential_backoff( config: RetryConfig, mut operation: F, ) -> Result where F: FnMut(usize) -> Result, { assert!(config.max_attempts > 0, "max_attempts must be greater than zero"); assert!(config.multiplier > 0, "multiplier must be greater than zero"); let mut delay = config.initial_delay.min(config.max_delay); for attempt in 1..=config.max_attempts { match operation(attempt) { Ok(value) => return Ok(value), Err(error) if attempt == config.max_attempts => return Err(error), Err(_) => { thread::sleep(delay); delay = next_delay(delay, config.multiplier, config.max_delay); } } } unreachable!("loop always returns because max_attempts is greater than zero") } fn next_delay(current: Duration, multiplier: u32, max_delay: Duration) -> Duration { current.saturating_mul(multiplier).min(max_delay) } #[cfg(test)] mod tests { use super::*; #[test] fn succeeds_after_retrying() { let config = RetryConfig { max_attempts: 3, initial_delay: Duration::ZERO, max_delay: Duration::ZERO, multiplier: 2, }; let mut calls = 0; let result = retry_with_exponential_backoff(config, |_| { calls += 1; if calls < 3 { Err("not yet") } else { Ok("done") } }); assert_eq!(result, Ok("done")); assert_eq!(calls, 3); } #[test] fn returns_last_error_after_attempt_limit() { let config = RetryConfig { max_attempts: 2, initial_delay: Duration::ZERO, max_delay: Duration::ZERO, multiplier: 2, }; let result: Result<(), usize> = retry_with_exponential_backoff(config, |attempt| Err(attempt)); assert_eq!(result, Err(2)); } #[test] fn caps_delay_at_maximum() { let first = Duration::from_millis(250); let max = Duration::from_millis(800); assert_eq!(next_delay(first, 2, max), Duration::from_millis(500)); assert_eq!(next_delay(Duration::from_millis(500), 2, max), max); } }