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 a_server_recommended_delay_stretches_the_backoff() {
169 let delay = Duration::from_secs(3);
173 let status = {
174 use tonic_types::{ErrorDetails, StatusExt as _};
175 let mut details = ErrorDetails::new();
176 details.set_retry_info(Some(delay));
177 tonic::Status::with_error_details(tonic::Code::Unavailable, "wait", details)
178 };
179
180 let started = tokio::time::Instant::now();
181 let calls = Cell::new(0);
182 let result: Result<u32> = run_with_retry(Some(&fast()), || {
183 calls.set(calls.get() + 1);
184 let n = calls.get();
185 let err = Error::from(status.clone());
186 async move { if n == 1 { Err(err) } else { Ok(n) } }
187 })
188 .await;
189
190 assert_eq!(result.unwrap(), 2);
191 assert!(
195 started.elapsed() >= delay,
196 "the server's delay is a minimum, slept only {:?}",
197 started.elapsed()
198 );
199 }
200
201 #[tokio::test]
202 async fn does_not_retry_non_retriable_errors() {
203 let calls = Cell::new(0);
204 let result: Result<u32> = run_with_retry(Some(&fast()), || {
205 calls.set(calls.get() + 1);
206 async move { Err(Error::InvalidRequest("nope".to_string())) }
207 })
208 .await;
209
210 assert!(result.is_err());
211 assert_eq!(calls.get(), 1, "non-retriable errors are not retried");
212 }
213
214 #[tokio::test]
215 async fn gives_up_after_max_attempts() {
216 let calls = Cell::new(0);
217 let result: Result<u32> = run_with_retry(Some(&fast()), || {
218 calls.set(calls.get() + 1);
219 async move { Err(Error::Timeout) }
220 })
221 .await;
222
223 assert!(result.is_err());
224 assert_eq!(calls.get(), 3, "stops at max_attempts");
225 }
226
227 #[tokio::test]
228 async fn attempt_timeout_bounds_a_hung_attempt() {
229 let calls = Cell::new(0);
230 let config = fast()
231 .with_max_attempts(2)
232 .with_attempt_timeout(Duration::from_millis(5));
233 let result: Result<u32> = run_with_retry(Some(&config), || {
234 calls.set(calls.get() + 1);
235 async move {
236 tokio::time::sleep(Duration::from_secs(30)).await;
239 Ok(1)
240 }
241 })
242 .await;
243
244 assert!(result.is_err(), "every attempt times out");
245 assert_eq!(calls.get(), 2, "the hung attempt is bounded and retried");
246 }
247
248 #[tokio::test]
249 async fn without_config_runs_exactly_once() {
250 let calls = Cell::new(0);
251 let result: Result<u32> = run_with_retry(None, || {
252 calls.set(calls.get() + 1);
253 async move { Err(Error::Timeout) }
254 })
255 .await;
256
257 assert!(result.is_err());
258 assert_eq!(calls.get(), 1);
259 }
260}