1use std::time::Duration;
9
10use crate::error::{Error, Result};
11
12const DEFAULT_MAX_ATTEMPTS: u32 = 3;
14const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_secs(1);
16const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(30);
18const DEFAULT_MULTIPLIER: f64 = 2.0;
20
21#[derive(Debug, Clone, Copy, PartialEq)]
23pub struct RetryPolicy {
24 pub max_attempts: u32,
27 pub initial_backoff: Duration,
29 pub max_backoff: Duration,
31 pub multiplier: f64,
33}
34
35impl Default for RetryPolicy {
36 fn default() -> Self {
39 Self {
40 max_attempts: DEFAULT_MAX_ATTEMPTS,
41 initial_backoff: DEFAULT_INITIAL_BACKOFF,
42 max_backoff: DEFAULT_MAX_BACKOFF,
43 multiplier: DEFAULT_MULTIPLIER,
44 }
45 }
46}
47
48impl RetryPolicy {
49 fn backoff(&self, attempt: u32) -> Duration {
52 let attempt = attempt.max(1);
53 let factor = self.multiplier.powi((attempt - 1) as i32);
55 let secs = self.initial_backoff.as_secs_f64() * factor;
56 let max_secs = self.max_backoff.as_secs_f64();
57 if !secs.is_finite() || secs >= max_secs {
61 return self.max_backoff;
62 }
63 if secs <= 0.0 {
64 return Duration::ZERO;
65 }
66 Duration::from_secs_f64(secs)
67 }
68}
69
70pub async fn retry<F, Fut, T>(policy: RetryPolicy, mut f: F) -> Result<T>
94where
95 F: FnMut() -> Fut,
96 Fut: std::future::Future<Output = Result<T>>,
97{
98 let max_attempts = policy.max_attempts.max(1);
99 let mut last_err: Option<Error> = None;
100
101 for attempt in 1..=max_attempts {
102 match f().await {
103 Ok(value) => return Ok(value),
104 Err(err) => {
105 if !err.is_retryable() {
107 return Err(err);
108 }
109 last_err = Some(err);
110 if attempt == max_attempts {
112 break;
113 }
114 let delay = policy.backoff(attempt);
115 tokio::time::sleep(delay).await;
116 }
117 }
118 }
119
120 Err(last_err.unwrap_or_else(|| Error::Other("retry: no attempts made".to_string())))
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use std::cell::Cell;
127
128 fn fast_policy(max_attempts: u32) -> RetryPolicy {
129 RetryPolicy {
130 max_attempts,
131 initial_backoff: Duration::from_millis(0),
132 max_backoff: Duration::from_millis(0),
133 multiplier: 2.0,
134 }
135 }
136
137 #[tokio::test]
138 async fn test_succeeds_first_try() {
139 let calls = Cell::new(0);
140 let result: Result<i32> = retry(fast_policy(3), || {
141 calls.set(calls.get() + 1);
142 async { Ok(7) }
143 })
144 .await;
145 assert_eq!(result.unwrap(), 7);
146 assert_eq!(calls.get(), 1);
147 }
148
149 #[tokio::test]
150 async fn test_retries_then_succeeds() {
151 let calls = Cell::new(0);
152 let result: Result<i32> = retry(fast_policy(3), || {
153 let n = calls.get() + 1;
154 calls.set(n);
155 async move {
156 if n < 3 {
157 Err(Error::connection("transient"))
159 } else {
160 Ok(99)
161 }
162 }
163 })
164 .await;
165 assert_eq!(result.unwrap(), 99);
166 assert_eq!(calls.get(), 3);
167 }
168
169 #[tokio::test]
170 async fn test_gives_up_after_max_attempts() {
171 let calls = Cell::new(0);
172 let result: Result<i32> = retry(fast_policy(3), || {
173 calls.set(calls.get() + 1);
174 async { Err(Error::timeout()) }
175 })
176 .await;
177 assert!(result.is_err());
178 assert_eq!(calls.get(), 3, "should attempt exactly max_attempts times");
179 }
180
181 #[tokio::test]
182 async fn test_does_not_retry_non_retryable_auth() {
183 let calls = Cell::new(0);
184 let result: Result<i32> = retry(fast_policy(5), || {
185 calls.set(calls.get() + 1);
186 async { Err(Error::auth("bad credentials")) }
187 })
188 .await;
189 assert!(result.is_err());
190 assert_eq!(calls.get(), 1, "non-retryable error must not be retried");
191 }
192
193 #[tokio::test]
194 async fn test_does_not_retry_non_retryable_validation() {
195 let calls = Cell::new(0);
196 let result: Result<i32> = retry(fast_policy(5), || {
197 calls.set(calls.get() + 1);
198 async { Err(Error::validation("bad input")) }
199 })
200 .await;
201 assert!(result.is_err());
202 assert_eq!(calls.get(), 1);
203 }
204
205 #[tokio::test]
206 async fn test_max_attempts_zero_treated_as_one() {
207 let calls = Cell::new(0);
208 let result: Result<i32> = retry(fast_policy(0), || {
209 calls.set(calls.get() + 1);
210 async { Err(Error::connection("transient")) }
211 })
212 .await;
213 assert!(result.is_err());
214 assert_eq!(calls.get(), 1);
215 }
216
217 #[test]
218 fn test_default_policy_values() {
219 let p = RetryPolicy::default();
220 assert_eq!(p.max_attempts, 3);
221 assert_eq!(p.initial_backoff, Duration::from_secs(1));
222 assert_eq!(p.max_backoff, Duration::from_secs(30));
223 assert_eq!(p.multiplier, 2.0);
224 }
225
226 #[test]
227 fn test_backoff_exponential_and_clamped() {
228 let p = RetryPolicy {
229 max_attempts: 10,
230 initial_backoff: Duration::from_secs(1),
231 max_backoff: Duration::from_secs(10),
232 multiplier: 2.0,
233 };
234 assert_eq!(p.backoff(1), Duration::from_secs(1)); assert_eq!(p.backoff(2), Duration::from_secs(2)); assert_eq!(p.backoff(3), Duration::from_secs(4)); assert_eq!(p.backoff(4), Duration::from_secs(8)); assert_eq!(p.backoff(5), Duration::from_secs(10));
240 assert_eq!(p.backoff(100), Duration::from_secs(10));
241 }
242
243 #[test]
244 fn test_backoff_negative_multiplier_does_not_panic() {
245 let p = RetryPolicy {
248 max_attempts: 5,
249 initial_backoff: Duration::from_secs(1),
250 max_backoff: Duration::from_secs(10),
251 multiplier: -2.0,
252 };
253 assert_eq!(p.backoff(2), Duration::ZERO);
255 assert_eq!(p.backoff(1), Duration::from_secs(1));
257 for attempt in 1..=5 {
259 let _ = p.backoff(attempt);
260 }
261 }
262}