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    #[allow(
60        clippy::cast_possible_truncation,
61        clippy::cast_sign_loss,
62        clippy::cast_precision_loss,
63        clippy::cast_possible_wrap
64    )]
65    pub fn delay_for_attempt(&self, attempt: usize) -> Duration {
66        match self {
67            Self::None => Duration::ZERO,
68
69            Self::Constant(d) => *d,
70
71            Self::Linear {
72                initial,
73                increment,
74                max,
75            } => {
76                let delay = *initial + (*increment * attempt as u32);
77                delay.min(*max)
78            }
79
80            Self::Exponential {
81                initial,
82                max,
83                multiplier,
84            } => {
85                let mult = multiplier.powi(attempt as i32);
86                let delay_nanos = initial.as_nanos() as f64 * mult;
87                let delay = Duration::from_nanos(delay_nanos as u64);
88                delay.min(*max)
89            }
90        }
91    }
92}
93
94impl Default for BackoffStrategy {
95    fn default() -> Self {
96        Self::Exponential {
97            initial: Duration::from_millis(100),
98            max: Duration::from_secs(30),
99            multiplier: 2.0,
100        }
101    }
102}
103
104/// Configuration for retry behavior.
105#[derive(Debug, Clone, Copy)]
106pub struct RetryConfig {
107    /// Maximum number of attempts (including first try).
108    pub max_attempts: usize,
109    /// Backoff strategy between attempts.
110    pub backoff: BackoffStrategy,
111    /// Whether to add jitter to delays.
112    pub jitter: bool,
113}
114
115impl RetryConfig {
116    /// Create a new retry configuration with defaults.
117    #[must_use]
118    pub fn new() -> Self {
119        Self::default()
120    }
121
122    /// Set maximum number of attempts.
123    #[must_use]
124    pub const fn max_attempts(mut self, n: usize) -> Self {
125        if n < 1 {
126            self.max_attempts = 1;
127        } else {
128            self.max_attempts = n;
129        }
130        self
131    }
132
133    /// Set backoff strategy.
134    #[must_use]
135    pub const fn backoff(mut self, strategy: BackoffStrategy) -> Self {
136        self.backoff = strategy;
137        self
138    }
139
140    /// Enable or disable jitter.
141    #[must_use]
142    pub const fn jitter(mut self, enabled: bool) -> Self {
143        self.jitter = enabled;
144        self
145    }
146
147    /// Create config for no retries.
148    #[must_use]
149    pub const fn no_retry() -> Self {
150        Self {
151            max_attempts: 1,
152            backoff: BackoffStrategy::None,
153            jitter: false,
154        }
155    }
156
157    /// Create config with simple constant delay.
158    #[must_use]
159    pub const fn with_constant_delay(attempts: usize, delay: Duration) -> Self {
160        Self {
161            max_attempts: attempts,
162            backoff: BackoffStrategy::Constant(delay),
163            jitter: false,
164        }
165    }
166
167    /// Create config with exponential backoff.
168    #[must_use]
169    pub const fn with_exponential_backoff(
170        attempts: usize,
171        initial: Duration,
172        max: Duration,
173    ) -> Self {
174        Self {
175            max_attempts: attempts,
176            backoff: BackoffStrategy::Exponential {
177                initial,
178                max,
179                multiplier: 2.0,
180            },
181            jitter: true,
182        }
183    }
184}
185
186impl Default for RetryConfig {
187    fn default() -> Self {
188        Self {
189            max_attempts: 3,
190            backoff: BackoffStrategy::default(),
191            jitter: true,
192        }
193    }
194}
195
196/// Result of a retry operation.
197#[derive(Debug)]
198pub struct RetryResult<T, E> {
199    /// The final result.
200    pub result: Result<T, E>,
201    /// Number of attempts made.
202    pub attempts: usize,
203    /// Total time spent (including delays).
204    pub total_time: Duration,
205}
206
207impl<T, E> RetryResult<T, E> {
208    /// Check if the operation succeeded.
209    #[must_use]
210    pub const fn is_ok(&self) -> bool {
211        self.result.is_ok()
212    }
213
214    /// Check if the operation failed.
215    #[must_use]
216    pub const fn is_err(&self) -> bool {
217        self.result.is_err()
218    }
219
220    /// Unwrap the result, panicking on error.
221    ///
222    /// # Panics
223    ///
224    /// Panics if the result is an error.
225    pub fn unwrap(self) -> T
226    where
227        E: std::fmt::Debug,
228    {
229        self.result.unwrap()
230    }
231
232    /// Get the result, converting error.
233    ///
234    /// # Errors
235    ///
236    /// Returns the last error if all retry attempts failed.
237    pub fn into_result(self) -> Result<T, E> {
238        self.result
239    }
240}
241
242/// Execute an operation with retries.
243///
244/// # Arguments
245///
246/// * `config` - Retry configuration
247/// * `operation` - The operation to retry
248///
249/// # Returns
250///
251/// The result of the operation, or the last error if all retries failed.
252///
253/// # Panics
254///
255/// Panics if `max_attempts` is somehow zero after internal clamping
256/// (should be unreachable).
257#[allow(
258    clippy::cast_possible_truncation,
259    clippy::cast_sign_loss,
260    clippy::cast_precision_loss
261)]
262pub fn retry<T, E, F>(config: RetryConfig, mut operation: F) -> RetryResult<T, E>
263where
264    F: FnMut() -> Result<T, E>,
265{
266    let start = std::time::Instant::now();
267    let mut last_error: Option<E> = None;
268    // Defensively clamp: public fields can bypass the builder's min-1 guard.
269    let max_attempts = config.max_attempts.max(1);
270
271    for attempt in 0..max_attempts {
272        match operation() {
273            Ok(value) => {
274                return RetryResult {
275                    result: Ok(value),
276                    attempts: attempt + 1,
277                    total_time: start.elapsed(),
278                };
279            }
280            Err(e) => {
281                last_error = Some(e);
282
283                // Don't sleep after the last attempt
284                if attempt + 1 < max_attempts {
285                    let mut delay = config.backoff.delay_for_attempt(attempt);
286
287                    // Add jitter (0-25% of delay)
288                    if config.jitter && delay > Duration::ZERO {
289                        let jitter_factor = simple_random() * 0.25;
290                        let jitter =
291                            Duration::from_nanos((delay.as_nanos() as f64 * jitter_factor) as u64);
292                        delay += jitter;
293                    }
294
295                    if delay > Duration::ZERO {
296                        thread::sleep(delay);
297                    }
298                }
299            }
300        }
301    }
302
303    RetryResult {
304        result: Err(last_error.expect("At least one attempt should have been made")),
305        attempts: max_attempts,
306        total_time: start.elapsed(),
307    }
308}
309
310/// Execute an operation with retries, with access to attempt number.
311///
312/// # Panics
313///
314/// Panics if `max_attempts` is somehow zero after internal clamping
315/// (should be unreachable).
316pub fn retry_with_context<T, E, F>(config: RetryConfig, mut operation: F) -> RetryResult<T, E>
317where
318    F: FnMut(usize) -> Result<T, E>,
319{
320    let start = std::time::Instant::now();
321    let mut last_error: Option<E> = None;
322    let max_attempts = config.max_attempts.max(1);
323
324    for attempt in 0..max_attempts {
325        match operation(attempt) {
326            Ok(value) => {
327                return RetryResult {
328                    result: Ok(value),
329                    attempts: attempt + 1,
330                    total_time: start.elapsed(),
331                };
332            }
333            Err(e) => {
334                last_error = Some(e);
335
336                if attempt + 1 < max_attempts {
337                    let delay = config.backoff.delay_for_attempt(attempt);
338                    if delay > Duration::ZERO {
339                        thread::sleep(delay);
340                    }
341                }
342            }
343        }
344    }
345
346    RetryResult {
347        result: Err(last_error.expect("At least one attempt should have been made")),
348        attempts: max_attempts,
349        total_time: start.elapsed(),
350    }
351}
352
353/// Simple pseudo-random number generator (0.0 to 1.0).
354fn simple_random() -> f64 {
355    use std::time::SystemTime;
356    let nanos = SystemTime::now()
357        .duration_since(SystemTime::UNIX_EPOCH)
358        .unwrap_or_default()
359        .subsec_nanos();
360    f64::from(nanos % 1000) / 1000.0
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use std::cell::Cell;
367
368    #[test]
369    fn test_retry_succeeds_first_try() {
370        let config = RetryConfig::new().max_attempts(3);
371        let result = retry(config, || Ok::<_, &str>("success"));
372
373        assert!(result.is_ok());
374        assert_eq!(result.attempts, 1);
375        assert_eq!(result.unwrap(), "success");
376    }
377
378    #[test]
379    fn test_retry_succeeds_after_failures() {
380        let attempts = Cell::new(0);
381        let config = RetryConfig::new()
382            .max_attempts(3)
383            .backoff(BackoffStrategy::None);
384
385        let result = retry(config, || {
386            let n = attempts.get();
387            attempts.set(n + 1);
388            if n < 2 { Err("not yet") } else { Ok("success") }
389        });
390
391        assert!(result.is_ok());
392        assert_eq!(result.attempts, 3);
393    }
394
395    #[test]
396    fn test_retry_exhausted() {
397        let config = RetryConfig::new()
398            .max_attempts(3)
399            .backoff(BackoffStrategy::None);
400
401        let result = retry(config, || Err::<(), _>("always fails"));
402
403        assert!(result.is_err());
404        assert_eq!(result.attempts, 3);
405    }
406
407    #[test]
408    fn test_backoff_constant() {
409        let strategy = BackoffStrategy::Constant(Duration::from_millis(100));
410        assert_eq!(strategy.delay_for_attempt(0), Duration::from_millis(100));
411        assert_eq!(strategy.delay_for_attempt(5), Duration::from_millis(100));
412    }
413
414    #[test]
415    fn test_backoff_exponential() {
416        let strategy = BackoffStrategy::Exponential {
417            initial: Duration::from_millis(100),
418            max: Duration::from_secs(10),
419            multiplier: 2.0,
420        };
421
422        assert_eq!(strategy.delay_for_attempt(0), Duration::from_millis(100));
423        assert_eq!(strategy.delay_for_attempt(1), Duration::from_millis(200));
424        assert_eq!(strategy.delay_for_attempt(2), Duration::from_millis(400));
425        assert_eq!(strategy.delay_for_attempt(3), Duration::from_millis(800));
426    }
427
428    #[test]
429    fn test_backoff_max_cap() {
430        let strategy = BackoffStrategy::Exponential {
431            initial: Duration::from_secs(1),
432            max: Duration::from_secs(5),
433            multiplier: 2.0,
434        };
435
436        // 1s * 2^10 = 1024s, but capped at 5s
437        assert_eq!(strategy.delay_for_attempt(10), Duration::from_secs(5));
438    }
439
440    #[test]
441    fn test_no_retry_config() {
442        let config = RetryConfig::no_retry();
443        assert_eq!(config.max_attempts, 1);
444    }
445
446    #[test]
447    fn test_zero_max_attempts_does_not_panic() {
448        // Bypass the builder by constructing directly with max_attempts = 0.
449        let config = RetryConfig {
450            max_attempts: 0,
451            backoff: BackoffStrategy::None,
452            jitter: false,
453        };
454        let result = retry(config, || Err::<(), _>("fail"));
455        // Should clamp to 1 attempt instead of panicking.
456        assert!(result.is_err());
457        assert_eq!(result.attempts, 1);
458    }
459
460    #[test]
461    fn test_zero_max_attempts_with_context() {
462        let config = RetryConfig {
463            max_attempts: 0,
464            backoff: BackoffStrategy::None,
465            jitter: false,
466        };
467        let result = retry_with_context(config, |_| Err::<(), _>("fail"));
468        assert!(result.is_err());
469        assert_eq!(result.attempts, 1);
470    }
471}