Skip to main content

loop_retry/
retry.rs

1//! Core retry logic with exponential backoff.
2
3use crate::config::RetryConfig;
4use crate::traits::IsRetryable;
5
6/// Retry an async operation with exponential backoff.
7///
8/// Calls `f` repeatedly until it succeeds or the max retries are exhausted.
9/// On each retryable error, waits for the calculated backoff delay before retrying.
10/// Non-retryable errors are returned immediately.
11///
12/// # Example
13///
14/// ```ignore
15/// use retry_backoff::{with_backoff, RetryConfig};
16///
17/// let result = with_backoff(&RetryConfig::default(), || async {
18///     client.get("https://api.example.com/data").await
19/// }).await;
20/// ```
21pub async fn with_backoff<F, Fut, T, E>(
22    config: &RetryConfig,
23    mut f: F,
24) -> Result<T, RetryError<E>>
25where
26    F: FnMut() -> Fut,
27    Fut: std::future::Future<Output = Result<T, E>>,
28    E: IsRetryable + std::fmt::Display,
29{
30    let mut last_error = None;
31
32    for attempt in 0..=config.max_retries {
33        match f().await {
34            Ok(value) => return Ok(value),
35            Err(err) => {
36                if !err.is_retryable() || attempt == config.max_retries {
37                    return Err(RetryError::FinalError(err, attempt));
38                }
39
40                let delay = config.delay_for_attempt(attempt);
41                last_error = Some(err);
42                tokio::time::sleep(delay).await;
43            }
44        }
45    }
46
47    Err(RetryError::FinalError(
48        last_error.expect("loop must have executed at least once"),
49        config.max_retries,
50    ))
51}
52
53/// Error type for retry operations.
54#[derive(Debug, thiserror::Error)]
55pub enum RetryError<E> {
56    /// The final error after all retries exhausted.
57    #[error("all {1} retries exhausted: {0}")]
58    FinalError(E, u32),
59}
60
61impl<E: IsRetryable + std::fmt::Display> RetryError<E> {
62    /// Get the inner error.
63    pub fn into_inner(self) -> E {
64        match self {
65            Self::FinalError(e, _) => e,
66        }
67    }
68
69    /// Get the number of retries attempted.
70    pub fn retries(&self) -> u32 {
71        match self {
72            Self::FinalError(_, r) => *r,
73        }
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use std::sync::atomic::{AtomicU32, Ordering};
81    use std::sync::Arc;
82    use std::time::Duration;
83
84    #[derive(Debug)]
85    struct TestError(bool);
86
87    impl std::fmt::Display for TestError {
88        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89            write!(f, "test error (retryable={})", self.0)
90        }
91    }
92
93    impl IsRetryable for TestError {
94        fn is_retryable(&self) -> bool {
95            self.0
96        }
97    }
98
99    #[tokio::test]
100    async fn succeeds_on_first_try() {
101        let config = RetryConfig::no_retries();
102        let result = with_backoff(&config, || async { Ok::<_, TestError>(42) }).await;
103        assert_eq!(result.unwrap(), 42);
104    }
105
106    #[tokio::test]
107    async fn retries_then_succeeds() {
108        let attempts = Arc::new(AtomicU32::new(0));
109        let attempts_clone = attempts.clone();
110        let config = RetryConfig {
111            initial_delay: Duration::from_millis(1),
112            max_retries: 3,
113            jitter: false,
114            ..Default::default()
115        };
116
117        let result = with_backoff(&config, || {
118            let a = attempts_clone.clone();
119            async move {
120                let count = a.fetch_add(1, Ordering::Relaxed);
121                if count < 2 {
122                    Err(TestError(true))
123                } else {
124                    Ok(42)
125                }
126            }
127        })
128        .await;
129
130        assert_eq!(result.unwrap(), 42);
131        assert_eq!(attempts.load(Ordering::Relaxed), 3);
132    }
133
134    #[tokio::test]
135    async fn non_retryable_error_returns_immediately() {
136        let config = RetryConfig::aggressive();
137        let result = with_backoff(&config, || async { Err::<i32, _>(TestError(false)) }).await;
138        assert!(result.is_err());
139        match result.unwrap_err() {
140            RetryError::FinalError(_, retries) => assert_eq!(retries, 0),
141        }
142    }
143
144    #[tokio::test]
145    async fn max_retries_exhausted() {
146        let config = RetryConfig {
147            initial_delay: Duration::from_millis(1),
148            max_retries: 2,
149            jitter: false,
150            ..Default::default()
151        };
152
153        let result = with_backoff(&config, || async { Err::<i32, _>(TestError(true)) }).await;
154        assert!(result.is_err());
155        match result.unwrap_err() {
156            RetryError::FinalError(_, retries) => assert_eq!(retries, 2),
157        }
158    }
159}