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 let recommended = err.retry_delay();
115 let delay = recommended.map_or(backoff, |server| server.max(backoff));
116 let delay = match recommended {
117 Some(minimum) => with_jitter(delay).max(minimum),
118 None => with_jitter(delay),
119 };
120 tokio::time::sleep(delay).await;
121 backoff = (backoff * 2).min(config.max_backoff);
122 attempt += 1;
123 }
124 Err(err) => return Err(err),
125 }
126 }
127}
128
129fn with_jitter(backoff: Duration) -> Duration {
133 let nanos = std::time::SystemTime::now()
134 .duration_since(std::time::UNIX_EPOCH)
135 .map_or(0, |d| d.subsec_nanos());
136 let factor = 0.5 + f64::from(nanos % 1024) / 1024.0;
137 backoff.mul_f64(factor)
138}
139
140#[cfg(test)]
141#[allow(clippy::unwrap_used)]
142mod tests {
143 use super::*;
144 use crate::Error;
145 use std::cell::Cell;
146
147 fn fast() -> RetryConfig {
148 RetryConfig::default()
149 .with_initial_backoff(Duration::from_millis(1))
150 .with_max_backoff(Duration::from_millis(1))
151 }
152
153 #[tokio::test]
154 async fn retries_retriable_errors_then_succeeds() {
155 let calls = Cell::new(0);
156 let result: Result<u32> = run_with_retry(Some(&fast()), || {
157 calls.set(calls.get() + 1);
158 let n = calls.get();
159 async move { if n < 3 { Err(Error::Timeout) } else { Ok(n) } }
160 })
161 .await;
162
163 assert_eq!(result.unwrap(), 3);
164 assert_eq!(calls.get(), 3);
165 }
166
167 #[tokio::test(start_paused = true)]
168 async fn the_backoff_doubles_each_attempt_up_to_the_cap() {
169 let cfg = RetryConfig::default()
176 .with_max_attempts(6)
177 .with_initial_backoff(Duration::from_millis(10))
178 .with_max_backoff(Duration::from_millis(80));
179
180 let starts = std::cell::RefCell::new(Vec::new());
181 let calls = Cell::new(0u32);
182 let _: Result<u32> = run_with_retry(Some(&cfg), || {
183 starts.borrow_mut().push(tokio::time::Instant::now());
184 calls.set(calls.get() + 1);
185 async move { Err::<u32, _>(Error::Timeout) }
186 })
187 .await;
188
189 let t = starts.borrow();
190 let gaps: Vec<u64> = t
191 .windows(2)
192 .map(|w| u64::try_from((w[1] - w[0]).as_millis()).unwrap_or(u64::MAX))
193 .collect();
194 let expected = [10u64, 20, 40, 80, 80];
195 assert_eq!(gaps.len(), expected.len(), "one gap per retry: {gaps:?}");
196 for (g, e) in gaps.iter().zip(expected) {
197 assert!(
200 *g * 2 >= e && *g * 2 <= e * 3,
201 "gap {g}ms outside jitter band of expected {e}ms; gaps={gaps:?}"
202 );
203 }
204 }
205
206 #[tokio::test(start_paused = true)]
207 async fn it_stops_after_max_attempts_rather_than_looping() {
208 let cfg = RetryConfig::default()
213 .with_max_attempts(4)
214 .with_initial_backoff(Duration::from_millis(1))
215 .with_max_backoff(Duration::from_millis(1));
216 let calls = Cell::new(0u32);
217 let result: Result<u32> = run_with_retry(Some(&cfg), || {
218 calls.set(calls.get() + 1);
219 async move { Err::<u32, _>(Error::Timeout) }
220 })
221 .await;
222 assert!(result.is_err());
223 assert_eq!(
224 calls.get(),
225 4,
226 "exactly max_attempts calls, no more, no fewer"
227 );
228 }
229
230 #[tokio::test(start_paused = true)]
231 async fn a_server_recommended_delay_stretches_the_backoff() {
232 let delay = Duration::from_secs(3);
236 let status = {
237 use tonic_types::{ErrorDetails, StatusExt as _};
238 let mut details = ErrorDetails::new();
239 details.set_retry_info(Some(delay));
240 tonic::Status::with_error_details(tonic::Code::Unavailable, "wait", details)
241 };
242
243 let started = tokio::time::Instant::now();
244 let calls = Cell::new(0);
245 let result: Result<u32> = run_with_retry(Some(&fast()), || {
246 calls.set(calls.get() + 1);
247 let n = calls.get();
248 let err = Error::from(status.clone());
249 async move { if n == 1 { Err(err) } else { Ok(n) } }
250 })
251 .await;
252
253 assert_eq!(result.unwrap(), 2);
254 assert!(
258 started.elapsed() >= delay,
259 "the server's delay is a minimum, slept only {:?}",
260 started.elapsed()
261 );
262 }
263
264 #[tokio::test]
265 async fn does_not_retry_non_retriable_errors() {
266 let calls = Cell::new(0);
267 let result: Result<u32> = run_with_retry(Some(&fast()), || {
268 calls.set(calls.get() + 1);
269 async move { Err(Error::InvalidRequest("nope".to_string())) }
270 })
271 .await;
272
273 assert!(result.is_err());
274 assert_eq!(calls.get(), 1, "non-retriable errors are not retried");
275 }
276
277 #[tokio::test]
278 async fn gives_up_after_max_attempts() {
279 let calls = Cell::new(0);
280 let result: Result<u32> = run_with_retry(Some(&fast()), || {
281 calls.set(calls.get() + 1);
282 async move { Err(Error::Timeout) }
283 })
284 .await;
285
286 assert!(result.is_err());
287 assert_eq!(calls.get(), 3, "stops at max_attempts");
288 }
289
290 #[tokio::test]
291 async fn attempt_timeout_bounds_a_hung_attempt() {
292 let calls = Cell::new(0);
293 let config = fast()
294 .with_max_attempts(2)
295 .with_attempt_timeout(Duration::from_millis(5));
296 let result: Result<u32> = run_with_retry(Some(&config), || {
297 calls.set(calls.get() + 1);
298 async move {
299 tokio::time::sleep(Duration::from_secs(30)).await;
302 Ok(1)
303 }
304 })
305 .await;
306
307 assert!(result.is_err(), "every attempt times out");
308 assert_eq!(calls.get(), 2, "the hung attempt is bounded and retried");
309 }
310
311 #[tokio::test]
312 async fn without_config_runs_exactly_once() {
313 let calls = Cell::new(0);
314 let result: Result<u32> = run_with_retry(None, || {
315 calls.set(calls.get() + 1);
316 async move { Err(Error::Timeout) }
317 })
318 .await;
319
320 assert!(result.is_err());
321 assert_eq!(calls.get(), 1);
322 }
323}