use std::thread::sleep; use std::time::Duration; /// Configuration for retrying an operation with exponential backoff. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct BackoffConfig { max_attempts: usize, initial_delay: Duration, max_delay: Duration, multiplier: u32, } impl BackoffConfig { pub fn new(max_attempts: usize, initial_delay: Duration) -> Self { Self { max_attempts: max_attempts.max(1), initial_delay, max_delay: Duration::from_secs(30), multiplier: 2, } } pub fn max_attempts(&self) -> usize { self.max_attempts } pub fn initial_delay(&self) -> Duration { self.initial_delay } pub fn max_delay(&self) -> Duration { self.max_delay } pub fn multiplier(&self) -> u32 { self.multiplier } pub fn with_max_attempts(mut self, max_attempts: usize) -> Self { self.max_attempts = max_attempts.max(1); self } pub fn with_initial_delay(mut self, initial_delay: Duration) -> Self { self.initial_delay = initial_delay; self } pub fn with_max_delay(mut self, max_delay: Duration) -> Self { self.max_delay = max_delay; self } pub fn with_multiplier(mut self, multiplier: u32) -> Self { self.multiplier = multiplier.max(1); self } fn next_delay(&self, current: Duration) -> Duration { current.saturating_mul(self.multiplier).min(self.max_delay) } } impl Default for BackoffConfig { fn default() -> Self { Self::new(3, Duration::from_millis(100)) } } /// Retries `operation` until it succeeds or `max_attempts` is reached. /// /// The closure receives the 1-based attempt number. pub fn retry(config: BackoffConfig, operation: F) -> Result where F: FnMut(usize) -> Result, { retry_if(config, |_| true, operation) } /// Like `retry`, but retries only when `should_retry` returns true for the error. pub fn retry_if( config: BackoffConfig, mut should_retry: P, mut operation: F, ) -> Result where F: FnMut(usize) -> Result, P: FnMut(&E) -> bool, { let mut delay = config.initial_delay; for attempt in 1..=config.max_attempts { match operation(attempt) { Ok(value) => return Ok(value), Err(error) if attempt == config.max_attempts || !should_retry(&error) => { return Err(error); } Err(_) => { if !delay.is_zero() { sleep(delay); } delay = config.next_delay(delay); } } } unreachable!("BackoffConfig always has at least one attempt") } #[cfg(test)] mod tests { use super::*; #[test] fn succeeds_after_retries() { let config = BackoffConfig::new(3, Duration::ZERO); let mut calls = 0; let result = retry(config, |_| { calls += 1; if calls < 3 { Err("not yet") } else { Ok("done") } }); assert_eq!(result, Ok("done")); assert_eq!(calls, 3); } #[test] fn stops_when_predicate_rejects_error() { let config = BackoffConfig::new(5, Duration::ZERO); let mut calls = 0; let result: Result<(), i32> = retry_if( config, |error| *error < 2, |_| { calls += 1; Err(calls) }, ); assert_eq!(result, Err(2)); assert_eq!(calls, 2); } }