shared_framework/retry/mod.rs
1//! Exponential-backoff retry for async operations.
2//!
3//! Build a [`RetryStrategy`] with the desired delays and attempt limit, then
4//! run an operation with [`retry`](RetryStrategy::retry) or
5//! [`with_exponential_backoff`](RetryStrategy::with_exponential_backoff).
6//! Failures sleep before the next attempt with the delay doubling each time up
7//! to the maximum; exhaustion returns [`RetryExhaustedError`].
8//!
9//! Key types: [`RetryStrategy`] for configuration and execution,
10//! [`RetryExhaustedError`] for the terminal failure.
11//!
12//! Use this module when a call may fail transiently and is safe to repeat.
13//!
14//! ```ignore
15//! # use std::time::Duration;
16//! # use crate::retry::RetryStrategy;
17//! # async fn example() -> Result<String, crate::retry::RetryExhaustedError> {
18//! let strategy = RetryStrategy::new()
19//! .with_base_delay(Duration::from_millis(100))
20//! .with_max_delay(Duration::from_secs(5))
21//! .with_max_attempts(3);
22//!
23//! strategy.retry(|| async { Ok::<_, std::io::Error>("ok".to_string()) }).await
24//! # }
25//! ```
26
27use std::time::Duration;
28use thiserror::Error;
29
30/// Terminal failure after all retry attempts were used.
31///
32/// Carries the number of attempts made and the last error as an `anyhow` error.
33#[derive(Debug, Error)]
34#[error("retry exhausted after {attempts} attempts: {source}")]
35pub struct RetryExhaustedError {
36 /// Number of attempts made, including the final one.
37 pub attempts: usize,
38 /// The last error seen before giving up.
39 pub source: anyhow::Error,
40}
41
42/// Configurable exponential-backoff policy.
43///
44/// Defaults: 1-second base delay, 30-second maximum delay, 12 attempts.
45/// Builders ([`with_base_delay`](Self::with_base_delay),
46/// [`with_max_delay`](Self::with_max_delay),
47/// [`with_max_attempts`](Self::with_max_attempts)) return the updated strategy.
48#[derive(Clone)]
49pub struct RetryStrategy {
50 base_delay: Duration,
51 max_delay: Duration,
52 max_attempts: usize,
53}
54
55impl RetryStrategy {
56 /// Creates a strategy with 1-second base delay, 30-second max delay, and 12 attempts.
57 pub fn new() -> Self {
58 Self { base_delay: Duration::from_secs(1), max_delay: Duration::from_secs(30), max_attempts: 12 }
59 }
60
61 /// Sets the initial delay before the first retry.
62 pub fn with_base_delay(mut self, d: Duration) -> Self { self.base_delay = d; self }
63 /// Sets the upper bound for the doubling delay.
64 pub fn with_max_delay(mut self, d: Duration) -> Self { self.max_delay = d; self }
65 /// Sets how many total attempts are made before giving up.
66 pub fn with_max_attempts(mut self, n: usize) -> Self { self.max_attempts = n; self }
67
68 /// Runs a boxed-future operation with exponential backoff.
69 ///
70 /// `F` is the closure producing each attempt's boxed future, `T` the success
71 /// value, and `E` the per-attempt error (converted to text on exhaustion).
72 /// Returns the first success, or [`RetryExhaustedError`] after `max_attempts`.
73 pub async fn with_exponential_backoff<F, T, E>(&self, mut f: F) -> Result<T, RetryExhaustedError>
74 where
75 F: FnMut() -> futures::future::BoxFuture<'static, Result<T, E>> + Send,
76 E: std::fmt::Display + Send + Sync + 'static,
77 T: Send,
78 {
79 let mut attempt = 0usize;
80 let mut delay = self.base_delay;
81 loop {
82 attempt += 1;
83 match f().await {
84 Ok(v) => return Ok(v),
85 Err(e) => {
86 if attempt >= self.max_attempts {
87 return Err(RetryExhaustedError { attempts: attempt, source: anyhow::anyhow!(e.to_string()) });
88 }
89 tokio::time::sleep(delay).await;
90 delay = std::cmp::min(delay * 2, self.max_delay);
91 }
92 }
93 }
94 }
95
96 /// Runs an operation returning a future with exponential backoff.
97 ///
98 /// `F` is the closure producing each attempt, `Fut` its future, `T` the
99 /// success value, and `E` the per-attempt error (converted to text on
100 /// exhaustion). Returns the first success, or [`RetryExhaustedError`]
101 /// after `max_attempts`.
102 pub async fn retry<F, Fut, T, E>(&self, mut op: F) -> Result<T, RetryExhaustedError>
103 where
104 F: FnMut() -> Fut + Send,
105 Fut: std::future::Future<Output = Result<T, E>> + Send,
106 E: std::fmt::Display + Send + Sync + 'static,
107 {
108 let mut attempt = 0usize;
109 let mut delay = self.base_delay;
110 loop {
111 attempt += 1;
112 match op().await {
113 Ok(v) => return Ok(v),
114 Err(e) => {
115 if attempt >= self.max_attempts {
116 return Err(RetryExhaustedError { attempts: attempt, source: anyhow::anyhow!(e.to_string()) });
117 }
118 tokio::time::sleep(delay).await;
119 delay = std::cmp::min(delay * 2, self.max_delay);
120 }
121 }
122 }
123 }
124}
125
126impl Default for RetryStrategy {
127 fn default() -> Self { Self::new() }
128}