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>(config: &RetryConfig, mut f: F) -> Result<T, RetryError<E>>
22where
23    F: FnMut() -> Fut,
24    Fut: std::future::Future<Output = Result<T, E>>,
25    E: IsRetryable + std::fmt::Display,
26{
27    for attempt in 0..=config.max_retries {
28        match f().await {
29            Ok(value) => return Ok(value),
30            Err(err) => {
31                if !err.is_retryable() || attempt == config.max_retries {
32                    #[cfg(feature = "tracing")]
33                    tracing::warn!(
34                        attempt,
35                        max_retries = config.max_retries,
36                        error = %err,
37                        "all retries exhausted"
38                    );
39                    return Err(RetryError::FinalError(err, attempt));
40                }
41
42                let delay = config.delay_for_attempt(attempt);
43                #[cfg(feature = "tracing")]
44                tracing::error!(
45                    attempt,
46                    max_retries = config.max_retries,
47                    error = %err,
48                    "error encountered, will retry"
49                );
50                #[cfg(feature = "tracing")]
51                tracing::debug!(
52                    attempt,
53                    max_retries = config.max_retries,
54                    delay_ms = delay.as_millis() as u64,
55                    error = %err,
56                    "retrying after error"
57                );
58                tokio::time::sleep(delay).await;
59            }
60        }
61    }
62
63    #[cfg(feature = "tracing")]
64    tracing::warn!(
65        max_retries = config.max_retries,
66        "all retries exhausted (unreachable path)"
67    );
68    // Unreachable: the loop's final iteration (`attempt == max_retries`)
69    // always returns `FinalError` above, and `0..=max_retries` executes at
70    // least once. Kept only so the function type-checks.
71    unreachable!("retry loop always returns on its final iteration");
72}
73
74/// Error type for retry operations.
75#[derive(Debug, thiserror::Error)]
76pub enum RetryError<E> {
77    /// The final error after all retries exhausted.
78    #[error("all {1} retries exhausted: {0}")]
79    FinalError(E, u32),
80}
81
82impl<E: IsRetryable + std::fmt::Display> RetryError<E> {
83    /// Get the inner error.
84    pub fn into_inner(self) -> E {
85        match self {
86            Self::FinalError(e, _) => e,
87        }
88    }
89
90    /// Get the number of retries attempted.
91    pub fn retries(&self) -> u32 {
92        match self {
93            Self::FinalError(_, r) => *r,
94        }
95    }
96}
97
98#[cfg(test)]
99// Test code: unwrap/unwrap_err/expect are the idiomatic way to assert outcomes.
100#[allow(clippy::unwrap_used, clippy::expect_used)]
101mod tests {
102    use super::*;
103    use std::sync::Arc;
104    use std::sync::atomic::{AtomicU32, Ordering};
105    use std::time::Duration;
106
107    #[derive(Debug)]
108    struct TestError(bool);
109
110    impl std::fmt::Display for TestError {
111        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112            write!(f, "test error (retryable={})", self.0)
113        }
114    }
115
116    impl IsRetryable for TestError {
117        fn is_retryable(&self) -> bool {
118            self.0
119        }
120    }
121
122    #[tokio::test]
123    async fn succeeds_on_first_try() {
124        let config = RetryConfig::no_retries();
125        let result = with_backoff(&config, || async { Ok::<_, TestError>(42) }).await;
126        assert_eq!(result.unwrap(), 42);
127    }
128
129    #[tokio::test]
130    async fn retries_then_succeeds() {
131        let attempts = Arc::new(AtomicU32::new(0));
132        let attempts_clone = attempts.clone();
133        let config = RetryConfig {
134            initial_delay: Duration::from_millis(1),
135            max_retries: 3,
136            jitter: false,
137            ..Default::default()
138        };
139
140        let result = with_backoff(&config, || {
141            let a = attempts_clone.clone();
142            async move {
143                let count = a.fetch_add(1, Ordering::Relaxed);
144                if count < 2 {
145                    Err(TestError(true))
146                } else {
147                    Ok(42)
148                }
149            }
150        })
151        .await;
152
153        assert_eq!(result.unwrap(), 42);
154        assert_eq!(attempts.load(Ordering::Relaxed), 3);
155    }
156
157    #[tokio::test]
158    async fn non_retryable_error_returns_immediately() {
159        let config = RetryConfig::aggressive();
160        let result = with_backoff(&config, || async { Err::<i32, _>(TestError(false)) }).await;
161        assert!(result.is_err());
162        match result.unwrap_err() {
163            RetryError::FinalError(_, retries) => assert_eq!(retries, 0),
164        }
165    }
166
167    #[tokio::test]
168    async fn max_retries_exhausted() {
169        let config = RetryConfig {
170            initial_delay: Duration::from_millis(1),
171            max_retries: 2,
172            jitter: false,
173            ..Default::default()
174        };
175
176        let result = with_backoff(&config, || async { Err::<i32, _>(TestError(true)) }).await;
177        assert!(result.is_err());
178        match result.unwrap_err() {
179            RetryError::FinalError(_, retries) => assert_eq!(retries, 2),
180        }
181    }
182}