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    ///
104    /// `pub` (not just used internally by [`RetryPolicy::execute`]) so a
105    /// caller that owns its own retry loop — notably `etdl-runtime-ffi`'s
106    /// synchronous, callback-driven `etdl_retry_policy_execute`, which
107    /// can't use this method's `async`, `tokio`-based sibling across an FFI
108    /// boundary — still computes the exact same backoff sequence instead of
109    /// re-deriving the formula.
110    pub fn delay_ms(&self, attempt: u32) -> u64 {
111        match self.strategy {
112            BackoffStrategy::Fixed => self.backoff_ms,
113            BackoffStrategy::Exponential => {
114                // 2^attempt saturates at u64::MAX for attempt >= 64.
115                let factor = 1u64.checked_shl(attempt).unwrap_or(u64::MAX);
116                self.backoff_ms.saturating_mul(factor)
117            }
118        }
119    }
120}
121
122impl Default for RetryPolicy {
123    fn default() -> Self {
124        RetryPolicy {
125            max_attempts: 1,
126            backoff_ms: 0,
127            strategy: BackoffStrategy::Fixed,
128        }
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[tokio::test]
137    async fn returns_first_ok() {
138        let policy = RetryPolicy::new(3, 1, BackoffStrategy::Fixed);
139        let mut calls = 0;
140        let result = policy
141            .execute(
142                || {
143                    calls += 1;
144                    async move {
145                        if calls == 1 {
146                            Err("first")
147                        } else {
148                            Ok(42)
149                        }
150                    }
151                },
152                Duration::from_millis(100),
153            )
154            .await;
155        assert_eq!(result, Ok(42));
156        assert_eq!(calls, 2);
157    }
158
159    #[tokio::test]
160    async fn returns_exhausted_with_last_error() {
161        let policy = RetryPolicy::new(2, 1, BackoffStrategy::Fixed);
162        let result: Result<i32, RetryError<&str>> = policy
163            .execute(
164                || async { Err::<i32, &str>("boom") },
165                Duration::from_millis(100),
166            )
167            .await;
168        match result {
169            Err(RetryError::Exhausted(e)) => assert_eq!(e, "boom"),
170            other => panic!("expected Exhausted, got {:?}", other),
171        }
172    }
173
174    #[tokio::test]
175    async fn returns_timed_out_when_all_timeout() {
176        let policy = RetryPolicy::new(2, 1, BackoffStrategy::Fixed);
177        let result: Result<i32, RetryError<&str>> = policy
178            .execute(
179                || async {
180                    tokio::time::sleep(Duration::from_millis(1000)).await;
181                    Ok::<i32, &str>(1)
182                },
183                Duration::from_millis(1),
184            )
185            .await;
186        assert!(matches!(result, Err(RetryError::TimedOut)));
187    }
188
189    #[tokio::test]
190    async fn zero_attempts_is_timed_out_not_panic() {
191        let policy = RetryPolicy::new(0, 0, BackoffStrategy::Fixed);
192        let result: Result<i32, RetryError<&str>> = policy
193            .execute(|| async { Ok(1) }, Duration::from_millis(1))
194            .await;
195        assert!(matches!(result, Err(RetryError::TimedOut)));
196    }
197
198    #[test]
199    fn exponential_backoff_saturates() {
200        let policy = RetryPolicy::new(100, u64::MAX, BackoffStrategy::Exponential);
201        // attempt 70: 2^70 overflows u64 -> saturates.
202        let d = policy.delay_ms(70);
203        assert_eq!(d, u64::MAX);
204    }
205}