1use std::future::Future;
9use std::time::Duration;
10
11use crate::Result;
12
13#[derive(Clone, Debug)]
18#[non_exhaustive]
19pub struct RetryConfig {
20 pub max_attempts: u32,
22 pub initial_backoff: Duration,
24 pub max_backoff: Duration,
26 pub attempt_timeout: Option<Duration>,
30}
31
32impl Default for RetryConfig {
33 fn default() -> Self {
34 Self {
35 max_attempts: 3,
36 initial_backoff: Duration::from_millis(100),
37 max_backoff: Duration::from_secs(5),
38 attempt_timeout: None,
39 }
40 }
41}
42
43impl RetryConfig {
44 #[must_use]
46 pub fn with_max_attempts(mut self, max_attempts: u32) -> Self {
47 self.max_attempts = max_attempts;
48 self
49 }
50
51 #[must_use]
53 pub fn with_initial_backoff(mut self, initial_backoff: Duration) -> Self {
54 self.initial_backoff = initial_backoff;
55 self
56 }
57
58 #[must_use]
60 pub fn with_max_backoff(mut self, max_backoff: Duration) -> Self {
61 self.max_backoff = max_backoff;
62 self
63 }
64
65 #[must_use]
68 pub fn with_attempt_timeout(mut self, attempt_timeout: Duration) -> Self {
69 self.attempt_timeout = Some(attempt_timeout);
70 self
71 }
72}
73
74pub async fn run_with_retry<T, F, Fut>(config: Option<&RetryConfig>, mut op: F) -> Result<T>
81where
82 F: FnMut() -> Fut,
83 Fut: Future<Output = Result<T>>,
84{
85 let Some(config) = config else {
86 return op().await;
87 };
88
89 let mut attempt = 1u32;
90 let mut backoff = config.initial_backoff;
91 loop {
92 let outcome = match config.attempt_timeout {
95 Some(timeout) => match tokio::time::timeout(timeout, op()).await {
96 Ok(result) => result,
97 Err(_) => Err(crate::Error::Timeout),
98 },
99 None => op().await,
100 };
101 match outcome {
102 Ok(value) => return Ok(value),
103 Err(err) if err.is_retriable() && attempt < config.max_attempts => {
104 tokio::time::sleep(with_jitter(backoff)).await;
105 backoff = (backoff * 2).min(config.max_backoff);
106 attempt += 1;
107 }
108 Err(err) => return Err(err),
109 }
110 }
111}
112
113fn with_jitter(backoff: Duration) -> Duration {
117 let nanos = std::time::SystemTime::now()
118 .duration_since(std::time::UNIX_EPOCH)
119 .map_or(0, |d| d.subsec_nanos());
120 let factor = 0.5 + f64::from(nanos % 1024) / 1024.0;
121 backoff.mul_f64(factor)
122}
123
124#[cfg(test)]
125#[allow(clippy::unwrap_used)]
126mod tests {
127 use super::*;
128 use crate::Error;
129 use std::cell::Cell;
130
131 fn fast() -> RetryConfig {
132 RetryConfig::default()
133 .with_initial_backoff(Duration::from_millis(1))
134 .with_max_backoff(Duration::from_millis(1))
135 }
136
137 #[tokio::test]
138 async fn retries_retriable_errors_then_succeeds() {
139 let calls = Cell::new(0);
140 let result: Result<u32> = run_with_retry(Some(&fast()), || {
141 calls.set(calls.get() + 1);
142 let n = calls.get();
143 async move { if n < 3 { Err(Error::Timeout) } else { Ok(n) } }
144 })
145 .await;
146
147 assert_eq!(result.unwrap(), 3);
148 assert_eq!(calls.get(), 3);
149 }
150
151 #[tokio::test]
152 async fn does_not_retry_non_retriable_errors() {
153 let calls = Cell::new(0);
154 let result: Result<u32> = run_with_retry(Some(&fast()), || {
155 calls.set(calls.get() + 1);
156 async move { Err(Error::InvalidRequest("nope".to_string())) }
157 })
158 .await;
159
160 assert!(result.is_err());
161 assert_eq!(calls.get(), 1, "non-retriable errors are not retried");
162 }
163
164 #[tokio::test]
165 async fn gives_up_after_max_attempts() {
166 let calls = Cell::new(0);
167 let result: Result<u32> = run_with_retry(Some(&fast()), || {
168 calls.set(calls.get() + 1);
169 async move { Err(Error::Timeout) }
170 })
171 .await;
172
173 assert!(result.is_err());
174 assert_eq!(calls.get(), 3, "stops at max_attempts");
175 }
176
177 #[tokio::test]
178 async fn attempt_timeout_bounds_a_hung_attempt() {
179 let calls = Cell::new(0);
180 let config = fast()
181 .with_max_attempts(2)
182 .with_attempt_timeout(Duration::from_millis(5));
183 let result: Result<u32> = run_with_retry(Some(&config), || {
184 calls.set(calls.get() + 1);
185 async move {
186 tokio::time::sleep(Duration::from_secs(30)).await;
189 Ok(1)
190 }
191 })
192 .await;
193
194 assert!(result.is_err(), "every attempt times out");
195 assert_eq!(calls.get(), 2, "the hung attempt is bounded and retried");
196 }
197
198 #[tokio::test]
199 async fn without_config_runs_exactly_once() {
200 let calls = Cell::new(0);
201 let result: Result<u32> = run_with_retry(None, || {
202 calls.set(calls.get() + 1);
203 async move { Err(Error::Timeout) }
204 })
205 .await;
206
207 assert!(result.is_err());
208 assert_eq!(calls.get(), 1);
209 }
210}