use std::thread; use std::time::Duration; /// Settings for retrying a fallible operation with exponential backoff. /// /// `max_attempts` includes the first attempt, so `max_attempts: 1` means /// "try once and do not retry." #[derive(Clone, Debug)] pub struct Backoff { pub max_attempts: usize, pub initial_delay: Duration, pub max_delay: Duration, pub multiplier: f64, } impl Backoff { pub fn new(max_attempts: usize, initial_delay: Duration) -> Self { assert!(max_attempts > 0, "max_attempts must be at least 1"); Self { max_attempts, initial_delay, max_delay: Duration::from_secs(30), multiplier: 2.0, } } pub fn with_max_delay(mut self, max_delay: Duration) -> Self { self.max_delay = max_delay; self } pub fn with_multiplier(mut self, multiplier: f64) -> Self { assert!(multiplier >= 1.0, "multiplier must be at least 1.0"); self.multiplier = multiplier; self } } /// Retry `operation` until it succeeds or the configured attempts are exhausted. /// /// The operation receives the 1-based attempt number. Between failed attempts, /// the current thread sleeps for the backoff delay. pub fn retry_with_backoff(operation: F, backoff: Backoff) -> Result where F: FnMut(usize) -> Result, { retry_with_backoff_using(operation, backoff, thread::sleep) } /// Same retry helper as `retry_with_backoff`, but with an injectable sleeper. /// /// This is useful for tests and for runtimes that need to record or customize /// waiting behavior. pub fn retry_with_backoff_using( mut operation: F, backoff: Backoff, mut sleep_for: S, ) -> Result where F: FnMut(usize) -> Result, S: FnMut(Duration), { assert!(backoff.max_attempts > 0, "max_attempts must be at least 1"); assert!(backoff.multiplier >= 1.0, "multiplier must be at least 1.0"); let mut delay = backoff.initial_delay; for attempt in 1..=backoff.max_attempts { match operation(attempt) { Ok(value) => return Ok(value), Err(err) if attempt == backoff.max_attempts => return Err(err), Err(_) => { sleep_for(delay); delay = next_delay(delay, backoff.multiplier, backoff.max_delay); } } } unreachable!("max_attempts is validated to be greater than zero") } 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_retries() { let backoff = Backoff::new(4, Duration::from_millis(10)); let mut attempts = 0; let mut delays = Vec::new(); let result = retry_with_backoff_using( |attempt| { attempts = attempt; if attempt < 3 { Err("not yet") } else { Ok("done") } }, backoff, |delay| delays.push(delay), ); assert_eq!(result, Ok("done")); assert_eq!(attempts, 3); assert_eq!( delays, vec![Duration::from_millis(10), Duration::from_millis(20)] ); } #[test] fn returns_last_error_after_max_attempts() { let backoff = Backoff::new(3, Duration::from_millis(5)) .with_multiplier(3.0) .with_max_delay(Duration::from_millis(12)); let mut delays = Vec::new(); let result: Result<(), usize> = retry_with_backoff_using(|attempt| Err(attempt), backoff, |delay| delays.push(delay)); assert_eq!(result, Err(3)); assert_eq!( delays, vec![Duration::from_millis(5), Duration::from_millis(12)] ); } }