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#[derive(Debug, PartialEq)]
20pub enum RetryError<E> {
21 Exhausted(E),
23 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 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 pub fn delay_ms(&self, attempt: u32) -> u64 {
111 match self.strategy {
112 BackoffStrategy::Fixed => self.backoff_ms,
113 BackoffStrategy::Exponential => {
114 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 let d = policy.delay_ms(70);
203 assert_eq!(d, u64::MAX);
204 }
205}