1use std::{convert::Infallible, future::Future, time::Duration};
4
5use tokio::time::Instant;
6
7use crate::error::Retry;
8
9#[derive(Debug, Clone)]
11pub(crate) struct RetryCfg {
12 pub(crate) max_retry_attempts: u32,
15
16 pub(crate) retry_base_delay: Duration,
19}
20
21impl Default for RetryCfg {
22 fn default() -> Self {
23 Self {
24 max_retry_attempts: 5,
25 retry_base_delay: Duration::from_secs(1),
26 }
27 }
28}
29
30#[derive(Debug)]
32pub(crate) enum GatedOutcome<T, E, A> {
33 Ok(T),
34 Aborted(A),
36 Failed(E),
38}
39
40pub(crate) async fn with_retries<F, Fut, T, E>(
44 cfg: &RetryCfg,
45 deadline: Option<Instant>,
46 op: F,
47) -> Result<T, E>
48where
49 F: FnMut() -> Fut,
50 Fut: Future<Output = Result<T, (Retry, E)>>,
51{
52 let outcome = retry_loop(cfg, deadline, || async { Ok::<(), Infallible>(()) }, op).await;
53
54 match outcome {
55 GatedOutcome::Ok(v) => Ok(v),
56 GatedOutcome::Failed(e) => Err(e),
57 GatedOutcome::Aborted(never) => match never {},
58 }
59}
60
61pub(crate) async fn with_retries_gated<G, GFut, F, Fut, T, E, A>(
66 cfg: &RetryCfg,
67 gate: G,
68 op: F,
69) -> GatedOutcome<T, E, A>
70where
71 G: FnMut() -> GFut,
72 GFut: Future<Output = Result<(), A>>,
73 F: FnMut() -> Fut,
74 Fut: Future<Output = Result<T, (Retry, E)>>,
75{
76 retry_loop(cfg, None, gate, op).await
77}
78
79async fn retry_loop<G, GFut, F, Fut, T, E, A>(
81 cfg: &RetryCfg,
82 deadline: Option<Instant>,
83 mut gate: G,
84 mut op: F,
85) -> GatedOutcome<T, E, A>
86where
87 G: FnMut() -> GFut,
88 GFut: Future<Output = Result<(), A>>,
89 F: FnMut() -> Fut,
90 Fut: Future<Output = Result<T, (Retry, E)>>,
91{
92 let max_tries = cfg.max_retry_attempts.max(1);
93 let base = cfg.retry_base_delay;
94 let mut last_err: Option<E> = None;
95
96 for attempt in 0..max_tries {
97 if let Err(abort) = gate().await {
98 return GatedOutcome::Aborted(abort);
99 }
100
101 match op().await {
102 Ok(v) => return GatedOutcome::Ok(v),
103 Err((Retry::Permanent, e)) => return GatedOutcome::Failed(e),
104 Err((Retry::Transient, e)) => {
105 last_err = Some(e);
106
107 if attempt + 1 >= max_tries {
108 break;
109 }
110
111 let Some(sleep) = cap_to_deadline(exponential_delay(base, attempt), deadline)
112 else {
113 break;
114 };
115 tokio::time::sleep(sleep).await;
116 }
117 }
118 }
119
120 #[allow(clippy::unwrap_used)]
122 GatedOutcome::Failed(last_err.unwrap())
123}
124
125fn exponential_delay(base: Duration, attempt: u32) -> Duration {
127 let shift = attempt.min(5);
128 base * (1u32 << shift)
129}
130
131fn cap_to_deadline(delay: Duration, deadline: Option<Instant>) -> Option<Duration> {
133 match deadline {
134 None => Some(delay),
135 Some(dl) => {
136 let now = Instant::now();
137 if now >= dl {
138 None
139 } else {
140 Some(delay.min(dl - now))
141 }
142 }
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use std::{
149 sync::{Arc, Mutex},
150 time::Duration,
151 };
152
153 use tokio::time::Instant;
154
155 use super::*;
156 use crate::error::Retry;
157
158 #[tokio::test(start_paused = true)]
159 async fn retry_calls_op_max_retry_attempts_times_on_transient() {
160 let calls = Arc::new(Mutex::new(0u32));
161 let cfg = RetryCfg {
162 max_retry_attempts: 5,
163 retry_base_delay: Duration::from_secs(1),
164 };
165
166 let calls_clone = Arc::clone(&calls);
167 let result = with_retries::<_, _, (), String>(&cfg, None, || {
168 let c = Arc::clone(&calls_clone);
169 async move {
170 *c.lock().unwrap() += 1;
171 Err((Retry::Transient, "transient".to_string()))
172 }
173 })
174 .await;
175
176 assert!(result.is_err());
177 assert_eq!(*calls.lock().unwrap(), 5, "should try exactly 5 times");
178 }
179
180 #[tokio::test(start_paused = true)]
181 async fn retry_zero_base_delay_still_retries() {
182 let calls = Arc::new(Mutex::new(0u32));
183 let cfg = RetryCfg {
184 max_retry_attempts: 3,
185 retry_base_delay: Duration::ZERO,
186 };
187
188 let calls_clone = Arc::clone(&calls);
189 let result = with_retries::<_, _, (), String>(&cfg, None, || {
190 let c = Arc::clone(&calls_clone);
191 async move {
192 *c.lock().unwrap() += 1;
193 Err((Retry::Transient, "transient".to_string()))
194 }
195 })
196 .await;
197
198 assert!(result.is_err());
199 assert_eq!(
200 *calls.lock().unwrap(),
201 3,
202 "zero delay should not stop retries"
203 );
204 }
205
206 #[tokio::test(start_paused = true)]
207 async fn retry_fatal_short_circuits_immediately() {
208 let calls = Arc::new(Mutex::new(0u32));
209 let cfg = RetryCfg {
210 max_retry_attempts: 5,
211 retry_base_delay: Duration::from_secs(1),
212 };
213
214 let calls_clone = Arc::clone(&calls);
215 let result = with_retries::<_, _, (), String>(&cfg, None, || {
216 let c = Arc::clone(&calls_clone);
217 async move {
218 *c.lock().unwrap() += 1;
219 Err((Retry::Permanent, "fatal".to_string()))
220 }
221 })
222 .await;
223
224 assert!(result.is_err());
225 assert_eq!(*calls.lock().unwrap(), 1, "fatal should stop after 1 call");
226 }
227
228 #[tokio::test(start_paused = true)]
229 async fn retry_succeeds_on_first_try() {
230 let cfg = RetryCfg::default();
231 let result = with_retries::<_, _, i32, String>(&cfg, None, || async { Ok(42) }).await;
232 assert_eq!(result, Ok(42));
233 }
234
235 #[tokio::test(start_paused = true)]
236 async fn retry_succeeds_on_third_try() {
237 let calls = Arc::new(Mutex::new(0u32));
238 let cfg = RetryCfg {
239 max_retry_attempts: 5,
240 retry_base_delay: Duration::from_secs(1),
241 };
242
243 let calls_clone = Arc::clone(&calls);
244 let result = with_retries::<_, _, i32, String>(&cfg, None, || {
245 let c = Arc::clone(&calls_clone);
246 async move {
247 let mut n = c.lock().unwrap();
248 *n += 1;
249 if *n >= 3 {
250 Ok(99)
251 } else {
252 Err((Retry::Transient, "wait".to_string()))
253 }
254 }
255 })
256 .await;
257
258 assert_eq!(result, Ok(99));
259 assert_eq!(*calls.lock().unwrap(), 3);
260 }
261
262 #[tokio::test(start_paused = true)]
263 async fn retry_deadline_in_past_stops_after_first_transient() {
264 let cfg = RetryCfg {
265 max_retry_attempts: 5,
266 retry_base_delay: Duration::from_secs(1),
267 };
268 let past = Instant::now().checked_sub(Duration::from_secs(1));
269 let Some(past_deadline) = past else {
271 return;
272 };
273
274 let calls = Arc::new(Mutex::new(0u32));
275 let calls_clone = Arc::clone(&calls);
276 let result = with_retries::<_, _, (), String>(&cfg, Some(past_deadline), || {
277 let c = Arc::clone(&calls_clone);
278 async move {
279 *c.lock().unwrap() += 1;
280 Err((Retry::Transient, "t".to_string()))
281 }
282 })
283 .await;
284
285 assert!(result.is_err());
286 assert_eq!(
287 *calls.lock().unwrap(),
288 1,
289 "past deadline should stop after first try"
290 );
291 }
292
293 #[tokio::test(start_paused = true)]
294 async fn gated_gate_called_before_each_try() {
295 let gate_calls = Arc::new(Mutex::new(0u32));
296 let op_calls = Arc::new(Mutex::new(0u32));
297 let cfg = RetryCfg {
298 max_retry_attempts: 3,
299 retry_base_delay: Duration::from_millis(10),
300 };
301
302 let gc = Arc::clone(&gate_calls);
303 let oc = Arc::clone(&op_calls);
304 let result = with_retries_gated::<_, _, _, _, (), String, ()>(
305 &cfg,
306 || {
307 let gc = Arc::clone(&gc);
308 async move {
309 *gc.lock().unwrap() += 1;
310 Ok(())
311 }
312 },
313 || {
314 let oc = Arc::clone(&oc);
315 async move {
316 *oc.lock().unwrap() += 1;
317 Err((Retry::Transient, "t".to_string()))
318 }
319 },
320 )
321 .await;
322
323 assert!(matches!(result, GatedOutcome::Failed(_)));
324 assert_eq!(
325 *gate_calls.lock().unwrap(),
326 3,
327 "gate called before each try"
328 );
329 assert_eq!(*op_calls.lock().unwrap(), 3);
330 }
331
332 #[tokio::test(start_paused = true)]
333 async fn gated_abort_between_tries_stops_before_next_op() {
334 let gate_calls = Arc::new(Mutex::new(0u32));
335 let op_calls = Arc::new(Mutex::new(0u32));
336 let cfg = RetryCfg {
337 max_retry_attempts: 5,
338 retry_base_delay: Duration::from_millis(10),
339 };
340
341 let gc = Arc::clone(&gate_calls);
342 let oc = Arc::clone(&op_calls);
343 let result = with_retries_gated::<_, _, _, _, (), String, &'static str>(
344 &cfg,
345 || {
346 let gc = Arc::clone(&gc);
347 async move {
348 let mut n = gc.lock().unwrap();
349 *n += 1;
350 if *n > 1 { Err("aborted") } else { Ok(()) }
351 }
352 },
353 || {
354 let oc = Arc::clone(&oc);
355 async move {
356 *oc.lock().unwrap() += 1;
357 Err((Retry::Transient, "t".to_string()))
358 }
359 },
360 )
361 .await;
362
363 assert!(matches!(result, GatedOutcome::Aborted("aborted")));
364 assert_eq!(*gate_calls.lock().unwrap(), 2);
366 assert_eq!(*op_calls.lock().unwrap(), 1);
368 }
369
370 #[tokio::test(start_paused = true)]
371 async fn gated_gate_abort_on_first_try_skips_op() {
372 let op_calls = Arc::new(Mutex::new(0u32));
373 let cfg = RetryCfg::default();
374
375 let oc = Arc::clone(&op_calls);
376 let result = with_retries_gated::<_, _, _, _, (), String, &'static str>(
377 &cfg,
378 || async { Err("immediate abort") },
379 || {
380 let oc = Arc::clone(&oc);
381 async move {
382 *oc.lock().unwrap() += 1;
383 Ok(())
384 }
385 },
386 )
387 .await;
388
389 assert!(matches!(result, GatedOutcome::Aborted("immediate abort")));
390 assert_eq!(
391 *op_calls.lock().unwrap(),
392 0,
393 "op never called when gate aborts first"
394 );
395 }
396
397 #[tokio::test(start_paused = true)]
398 async fn gated_fatal_short_circuits() {
399 let gate_calls = Arc::new(Mutex::new(0u32));
400 let cfg = RetryCfg {
401 max_retry_attempts: 5,
402 retry_base_delay: Duration::from_millis(10),
403 };
404
405 let gc = Arc::clone(&gate_calls);
406 let result = with_retries_gated::<_, _, _, _, (), String, ()>(
407 &cfg,
408 || {
409 let gc = Arc::clone(&gc);
410 async move {
411 *gc.lock().unwrap() += 1;
412 Ok(())
413 }
414 },
415 || async { Err((Retry::Permanent, "fatal".to_string())) },
416 )
417 .await;
418
419 assert!(matches!(result, GatedOutcome::Failed(_)));
420 assert_eq!(
421 *gate_calls.lock().unwrap(),
422 1,
423 "only one gate call for fatal"
424 );
425 }
426
427 #[tokio::test(start_paused = true)]
428 async fn gated_ok_on_first_try() {
429 let cfg = RetryCfg::default();
430 let result = with_retries_gated::<_, _, _, _, i32, String, ()>(
431 &cfg,
432 || async { Ok(()) },
433 || async { Ok(42) },
434 )
435 .await;
436 assert!(matches!(result, GatedOutcome::Ok(42)));
437 }
438}