Skip to main content

etdl_core/
retry.rs

1use std::fmt;
2use std::future::Future;
3use std::time::Duration;
4
5#[derive(Debug, Clone, Copy, PartialEq)]
6pub enum BackoffStrategy {
7    Fixed,
8    Exponential,
9}
10
11#[derive(Debug, Clone)]
12pub struct RetryPolicy {
13    pub max_attempts: u32,
14    pub backoff_ms: u64,
15    pub strategy: BackoffStrategy,
16}
17
18/// The failure mode of a retried operation once attempts are exhausted.
19#[derive(Debug, PartialEq)]
20pub enum RetryError<E> {
21    /// The handler returned `Err` on its final attempt.
22    Exhausted(E),
23    /// Every attempt timed out (or no attempt was made with `max_attempts == 0`),
24    /// so no handler error is available.
25    TimedOut,
26}
27
28impl<E: fmt::Display> fmt::Display for RetryError<E> {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            RetryError::Exhausted(e) => write!(f, "retry exhausted: {}", e),
32            RetryError::TimedOut => write!(f, "retry exhausted: all attempts timed out"),
33        }
34    }
35}
36
37impl<E: std::error::Error + 'static> std::error::Error for RetryError<E> {
38    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
39        match self {
40            RetryError::Exhausted(e) => Some(e),
41            RetryError::TimedOut => None,
42        }
43    }
44}
45
46impl RetryPolicy {
47    pub fn new(max_attempts: u32, backoff_ms: u64, strategy: BackoffStrategy) -> Self {
48        RetryPolicy {
49            max_attempts,
50            backoff_ms,
51            strategy,
52        }
53    }
54
55    /// Run `f` up to `max_attempts` times, each under `timeout`.
56    ///
57    /// - The first `Ok` is returned immediately.
58    /// - `Err` is retained as the last error and retried.
59    /// - A timeout is recorded and retried (no error value is produced by a
60    ///   timeout).
61    ///
62    /// If attempts are exhausted with a captured handler error, that error is
63    /// returned as [`RetryError::Exhausted`]. If they are exhausted with only
64    /// timeouts (or `max_attempts == 0`), [`RetryError::TimedOut`] is returned.
65    /// This method never panics.
66    pub async fn execute<F, Fut, T, E>(
67        &self,
68        mut f: F,
69        timeout: Duration,
70    ) -> Result<T, RetryError<E>>
71    where
72        F: FnMut() -> Fut,
73        Fut: Future<Output = Result<T, E>>,
74        E: std::fmt::Debug,
75    {
76        let mut last_error: Option<E> = None;
77
78        for attempt in 0..self.max_attempts {
79            match tokio::time::timeout(timeout, f()).await {
80                Ok(Ok(result)) => return Ok(result),
81                Ok(Err(err)) => {
82                    last_error = Some(err);
83                }
84                Err(_elapsed) => {
85                    eprintln!("[etdl] retry attempt {} timed out", attempt + 1);
86                }
87            }
88
89            if attempt < self.max_attempts - 1 {
90                let delay = Duration::from_millis(self.delay_ms(attempt));
91                tokio::time::sleep(delay).await;
92            }
93        }
94
95        match last_error {
96            Some(err) => Err(RetryError::Exhausted(err)),
97            None => Err(RetryError::TimedOut),
98        }
99    }
100
101    /// Compute the backoff delay (ms) before attempt `attempt`, saturating so a
102    /// document-controlled `max_attempts`/`backoff_ms` can never overflow.
103    fn delay_ms(&self, attempt: u32) -> u64 {
104        match self.strategy {
105            BackoffStrategy::Fixed => self.backoff_ms,
106            BackoffStrategy::Exponential => {
107                // 2^attempt saturates at u64::MAX for attempt >= 64.
108                let factor = 1u64.checked_shl(attempt).unwrap_or(u64::MAX);
109                self.backoff_ms.saturating_mul(factor)
110            }
111        }
112    }
113}
114
115impl Default for RetryPolicy {
116    fn default() -> Self {
117        RetryPolicy {
118            max_attempts: 1,
119            backoff_ms: 0,
120            strategy: BackoffStrategy::Fixed,
121        }
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[tokio::test]
130    async fn returns_first_ok() {
131        let policy = RetryPolicy::new(3, 1, BackoffStrategy::Fixed);
132        let mut calls = 0;
133        let result = policy
134            .execute(
135                || {
136                    calls += 1;
137                    async move {
138                        if calls == 1 {
139                            Err("first")
140                        } else {
141                            Ok(42)
142                        }
143                    }
144                },
145                Duration::from_millis(100),
146            )
147            .await;
148        assert_eq!(result, Ok(42));
149        assert_eq!(calls, 2);
150    }
151
152    #[tokio::test]
153    async fn returns_exhausted_with_last_error() {
154        let policy = RetryPolicy::new(2, 1, BackoffStrategy::Fixed);
155        let result: Result<i32, RetryError<&str>> = policy
156            .execute(
157                || async { Err::<i32, &str>("boom") },
158                Duration::from_millis(100),
159            )
160            .await;
161        match result {
162            Err(RetryError::Exhausted(e)) => assert_eq!(e, "boom"),
163            other => panic!("expected Exhausted, got {:?}", other),
164        }
165    }
166
167    #[tokio::test]
168    async fn returns_timed_out_when_all_timeout() {
169        let policy = RetryPolicy::new(2, 1, BackoffStrategy::Fixed);
170        let result: Result<i32, RetryError<&str>> = policy
171            .execute(
172                || async {
173                    tokio::time::sleep(Duration::from_millis(1000)).await;
174                    Ok::<i32, &str>(1)
175                },
176                Duration::from_millis(1),
177            )
178            .await;
179        assert!(matches!(result, Err(RetryError::TimedOut)));
180    }
181
182    #[tokio::test]
183    async fn zero_attempts_is_timed_out_not_panic() {
184        let policy = RetryPolicy::new(0, 0, BackoffStrategy::Fixed);
185        let result: Result<i32, RetryError<&str>> = policy
186            .execute(|| async { Ok(1) }, Duration::from_millis(1))
187            .await;
188        assert!(matches!(result, Err(RetryError::TimedOut)));
189    }
190
191    #[test]
192    fn exponential_backoff_saturates() {
193        let policy = RetryPolicy::new(100, u64::MAX, BackoffStrategy::Exponential);
194        // attempt 70: 2^70 overflows u64 -> saturates.
195        let d = policy.delay_ms(70);
196        assert_eq!(d, u64::MAX);
197    }
198}