Skip to main content

commons/
retry.rs

1//! Retry logic with configurable backoff strategies.
2//!
3//! # Example
4//!
5//! ```rust
6//! use commons::retry::{retry, RetryConfig, BackoffStrategy};
7//! use std::time::Duration;
8//!
9//! let config = RetryConfig::new()
10//!     .max_attempts(3)
11//!     .backoff(BackoffStrategy::Exponential {
12//!         initial: Duration::from_millis(100),
13//!         max: Duration::from_secs(5),
14//!         multiplier: 2.0,
15//!     });
16//!
17//! let result = retry(config, || {
18//!     // Operation that might fail
19//!     Ok::<_, &str>("success")
20//! });
21//! ```
22
23use std::thread;
24use std::time::Duration;
25
26/// Backoff strategy for retries.
27#[derive(Debug, Clone, Copy)]
28pub enum BackoffStrategy {
29    /// No delay between retries.
30    None,
31
32    /// Constant delay between retries.
33    Constant(Duration),
34
35    /// Linear backoff: delay increases linearly.
36    Linear {
37        /// Initial delay.
38        initial: Duration,
39        /// Increment per attempt.
40        increment: Duration,
41        /// Maximum delay.
42        max: Duration,
43    },
44
45    /// Exponential backoff: delay doubles each attempt.
46    Exponential {
47        /// Initial delay.
48        initial: Duration,
49        /// Maximum delay.
50        max: Duration,
51        /// Multiplier (typically 2.0).
52        multiplier: f64,
53    },
54}
55
56impl BackoffStrategy {
57    /// Calculate delay for a given attempt number (0-indexed).
58    #[must_use]
59    pub fn delay_for_attempt(&self, attempt: usize) -> Duration {
60        match self {
61            Self::None => Duration::ZERO,
62
63            Self::Constant(d) => *d,
64
65            Self::Linear {
66                initial,
67                increment,
68                max,
69            } => {
70                let delay = *initial + (*increment * attempt as u32);
71                delay.min(*max)
72            }
73
74            Self::Exponential {
75                initial,
76                max,
77                multiplier,
78            } => {
79                let mult = multiplier.powi(attempt as i32);
80                let delay_nanos = initial.as_nanos() as f64 * mult;
81                let delay = Duration::from_nanos(delay_nanos as u64);
82                delay.min(*max)
83            }
84        }
85    }
86}
87
88impl Default for BackoffStrategy {
89    fn default() -> Self {
90        Self::Exponential {
91            initial: Duration::from_millis(100),
92            max: Duration::from_secs(30),
93            multiplier: 2.0,
94        }
95    }
96}
97
98/// Configuration for retry behavior.
99#[derive(Debug, Clone, Copy)]
100pub struct RetryConfig {
101    /// Maximum number of attempts (including first try).
102    pub max_attempts: usize,
103    /// Backoff strategy between attempts.
104    pub backoff: BackoffStrategy,
105    /// Whether to add jitter to delays.
106    pub jitter: bool,
107}
108
109impl RetryConfig {
110    /// Create a new retry configuration with defaults.
111    #[must_use]
112    pub fn new() -> Self {
113        Self::default()
114    }
115
116    /// Set maximum number of attempts.
117    #[must_use]
118    pub fn max_attempts(mut self, n: usize) -> Self {
119        self.max_attempts = n.max(1);
120        self
121    }
122
123    /// Set backoff strategy.
124    #[must_use]
125    pub fn backoff(mut self, strategy: BackoffStrategy) -> Self {
126        self.backoff = strategy;
127        self
128    }
129
130    /// Enable or disable jitter.
131    #[must_use]
132    pub fn jitter(mut self, enabled: bool) -> Self {
133        self.jitter = enabled;
134        self
135    }
136
137    /// Create config for no retries.
138    #[must_use]
139    pub fn no_retry() -> Self {
140        Self {
141            max_attempts: 1,
142            backoff: BackoffStrategy::None,
143            jitter: false,
144        }
145    }
146
147    /// Create config with simple constant delay.
148    #[must_use]
149    pub fn with_constant_delay(attempts: usize, delay: Duration) -> Self {
150        Self {
151            max_attempts: attempts,
152            backoff: BackoffStrategy::Constant(delay),
153            jitter: false,
154        }
155    }
156
157    /// Create config with exponential backoff.
158    #[must_use]
159    pub fn with_exponential_backoff(attempts: usize, initial: Duration, max: Duration) -> Self {
160        Self {
161            max_attempts: attempts,
162            backoff: BackoffStrategy::Exponential {
163                initial,
164                max,
165                multiplier: 2.0,
166            },
167            jitter: true,
168        }
169    }
170}
171
172impl Default for RetryConfig {
173    fn default() -> Self {
174        Self {
175            max_attempts: 3,
176            backoff: BackoffStrategy::default(),
177            jitter: true,
178        }
179    }
180}
181
182/// Result of a retry operation.
183#[derive(Debug)]
184pub struct RetryResult<T, E> {
185    /// The final result.
186    pub result: Result<T, E>,
187    /// Number of attempts made.
188    pub attempts: usize,
189    /// Total time spent (including delays).
190    pub total_time: Duration,
191}
192
193impl<T, E> RetryResult<T, E> {
194    /// Check if the operation succeeded.
195    #[must_use]
196    pub fn is_ok(&self) -> bool {
197        self.result.is_ok()
198    }
199
200    /// Check if the operation failed.
201    #[must_use]
202    pub fn is_err(&self) -> bool {
203        self.result.is_err()
204    }
205
206    /// Unwrap the result, panicking on error.
207    pub fn unwrap(self) -> T
208    where
209        E: std::fmt::Debug,
210    {
211        self.result.unwrap()
212    }
213
214    /// Get the result, converting error.
215    pub fn into_result(self) -> Result<T, E> {
216        self.result
217    }
218}
219
220/// Execute an operation with retries.
221///
222/// # Arguments
223///
224/// * `config` - Retry configuration
225/// * `operation` - The operation to retry
226///
227/// # Returns
228///
229/// The result of the operation, or the last error if all retries failed.
230pub fn retry<T, E, F>(config: RetryConfig, mut operation: F) -> RetryResult<T, E>
231where
232    F: FnMut() -> Result<T, E>,
233{
234    let start = std::time::Instant::now();
235    let mut last_error: Option<E> = None;
236
237    for attempt in 0..config.max_attempts {
238        match operation() {
239            Ok(value) => {
240                return RetryResult {
241                    result: Ok(value),
242                    attempts: attempt + 1,
243                    total_time: start.elapsed(),
244                };
245            }
246            Err(e) => {
247                last_error = Some(e);
248
249                // Don't sleep after the last attempt
250                if attempt + 1 < config.max_attempts {
251                    let mut delay = config.backoff.delay_for_attempt(attempt);
252
253                    // Add jitter (0-25% of delay)
254                    if config.jitter && delay > Duration::ZERO {
255                        let jitter_factor = simple_random() * 0.25;
256                        let jitter =
257                            Duration::from_nanos((delay.as_nanos() as f64 * jitter_factor) as u64);
258                        delay += jitter;
259                    }
260
261                    if delay > Duration::ZERO {
262                        thread::sleep(delay);
263                    }
264                }
265            }
266        }
267    }
268
269    RetryResult {
270        result: Err(last_error.expect("At least one attempt should have been made")),
271        attempts: config.max_attempts,
272        total_time: start.elapsed(),
273    }
274}
275
276/// Execute an operation with retries, with access to attempt number.
277pub fn retry_with_context<T, E, F>(config: RetryConfig, mut operation: F) -> RetryResult<T, E>
278where
279    F: FnMut(usize) -> Result<T, E>,
280{
281    let start = std::time::Instant::now();
282    let mut last_error: Option<E> = None;
283
284    for attempt in 0..config.max_attempts {
285        match operation(attempt) {
286            Ok(value) => {
287                return RetryResult {
288                    result: Ok(value),
289                    attempts: attempt + 1,
290                    total_time: start.elapsed(),
291                };
292            }
293            Err(e) => {
294                last_error = Some(e);
295
296                if attempt + 1 < config.max_attempts {
297                    let delay = config.backoff.delay_for_attempt(attempt);
298                    if delay > Duration::ZERO {
299                        thread::sleep(delay);
300                    }
301                }
302            }
303        }
304    }
305
306    RetryResult {
307        result: Err(last_error.expect("At least one attempt should have been made")),
308        attempts: config.max_attempts,
309        total_time: start.elapsed(),
310    }
311}
312
313/// Simple pseudo-random number generator (0.0 to 1.0).
314fn simple_random() -> f64 {
315    use std::time::SystemTime;
316    let nanos = SystemTime::now()
317        .duration_since(SystemTime::UNIX_EPOCH)
318        .unwrap_or_default()
319        .subsec_nanos();
320    (nanos % 1000) as f64 / 1000.0
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use std::cell::Cell;
327
328    #[test]
329    fn test_retry_succeeds_first_try() {
330        let config = RetryConfig::new().max_attempts(3);
331        let result = retry(config, || Ok::<_, &str>("success"));
332
333        assert!(result.is_ok());
334        assert_eq!(result.attempts, 1);
335        assert_eq!(result.unwrap(), "success");
336    }
337
338    #[test]
339    fn test_retry_succeeds_after_failures() {
340        let attempts = Cell::new(0);
341        let config = RetryConfig::new()
342            .max_attempts(3)
343            .backoff(BackoffStrategy::None);
344
345        let result = retry(config, || {
346            let n = attempts.get();
347            attempts.set(n + 1);
348            if n < 2 { Err("not yet") } else { Ok("success") }
349        });
350
351        assert!(result.is_ok());
352        assert_eq!(result.attempts, 3);
353    }
354
355    #[test]
356    fn test_retry_exhausted() {
357        let config = RetryConfig::new()
358            .max_attempts(3)
359            .backoff(BackoffStrategy::None);
360
361        let result = retry(config, || Err::<(), _>("always fails"));
362
363        assert!(result.is_err());
364        assert_eq!(result.attempts, 3);
365    }
366
367    #[test]
368    fn test_backoff_constant() {
369        let strategy = BackoffStrategy::Constant(Duration::from_millis(100));
370        assert_eq!(strategy.delay_for_attempt(0), Duration::from_millis(100));
371        assert_eq!(strategy.delay_for_attempt(5), Duration::from_millis(100));
372    }
373
374    #[test]
375    fn test_backoff_exponential() {
376        let strategy = BackoffStrategy::Exponential {
377            initial: Duration::from_millis(100),
378            max: Duration::from_secs(10),
379            multiplier: 2.0,
380        };
381
382        assert_eq!(strategy.delay_for_attempt(0), Duration::from_millis(100));
383        assert_eq!(strategy.delay_for_attempt(1), Duration::from_millis(200));
384        assert_eq!(strategy.delay_for_attempt(2), Duration::from_millis(400));
385        assert_eq!(strategy.delay_for_attempt(3), Duration::from_millis(800));
386    }
387
388    #[test]
389    fn test_backoff_max_cap() {
390        let strategy = BackoffStrategy::Exponential {
391            initial: Duration::from_secs(1),
392            max: Duration::from_secs(5),
393            multiplier: 2.0,
394        };
395
396        // 1s * 2^10 = 1024s, but capped at 5s
397        assert_eq!(strategy.delay_for_attempt(10), Duration::from_secs(5));
398    }
399
400    #[test]
401    fn test_no_retry_config() {
402        let config = RetryConfig::no_retry();
403        assert_eq!(config.max_attempts, 1);
404    }
405}