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 fn delay_ms(&self, attempt: u32) -> u64 {
104 match self.strategy {
105 BackoffStrategy::Fixed => self.backoff_ms,
106 BackoffStrategy::Exponential => {
107 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 let d = policy.delay_ms(70);
196 assert_eq!(d, u64::MAX);
197 }
198}