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 delay = err
108 .retry_delay()
109 .map_or(backoff, |server| server.max(backoff));
110 tokio::time::sleep(with_jitter(delay)).await;
111 backoff = (backoff * 2).min(config.max_backoff);
112 attempt += 1;
113 }
114 Err(err) => return Err(err),
115 }
116 }
117}
118
119fn with_jitter(backoff: Duration) -> Duration {
123 let nanos = std::time::SystemTime::now()
124 .duration_since(std::time::UNIX_EPOCH)
125 .map_or(0, |d| d.subsec_nanos());
126 let factor = 0.5 + f64::from(nanos % 1024) / 1024.0;
127 backoff.mul_f64(factor)
128}
129
130#[cfg(test)]
131#[allow(clippy::unwrap_used)]
132mod tests {
133 use super::*;
134 use crate::Error;
135 use std::cell::Cell;
136
137 fn fast() -> RetryConfig {
138 RetryConfig::default()
139 .with_initial_backoff(Duration::from_millis(1))
140 .with_max_backoff(Duration::from_millis(1))
141 }
142
143 #[tokio::test]
144 async fn retries_retriable_errors_then_succeeds() {
145 let calls = Cell::new(0);
146 let result: Result<u32> = run_with_retry(Some(&fast()), || {
147 calls.set(calls.get() + 1);
148 let n = calls.get();
149 async move { if n < 3 { Err(Error::Timeout) } else { Ok(n) } }
150 })
151 .await;
152
153 assert_eq!(result.unwrap(), 3);
154 assert_eq!(calls.get(), 3);
155 }
156
157 #[tokio::test(start_paused = true)]
158 async fn a_server_recommended_delay_stretches_the_backoff() {
159 let delay = Duration::from_secs(3);
163 let status = {
164 use tonic_types::{ErrorDetails, StatusExt as _};
165 let mut details = ErrorDetails::new();
166 details.set_retry_info(Some(delay));
167 tonic::Status::with_error_details(tonic::Code::Unavailable, "wait", details)
168 };
169
170 let started = tokio::time::Instant::now();
171 let calls = Cell::new(0);
172 let result: Result<u32> = run_with_retry(Some(&fast()), || {
173 calls.set(calls.get() + 1);
174 let n = calls.get();
175 let err = Error::from(status.clone());
176 async move { if n == 1 { Err(err) } else { Ok(n) } }
177 })
178 .await;
179
180 assert_eq!(result.unwrap(), 2);
181 assert!(
183 started.elapsed() >= delay / 2,
184 "the server's delay must be honoured, slept only {:?}",
185 started.elapsed()
186 );
187 }
188
189 #[tokio::test]
190 async fn does_not_retry_non_retriable_errors() {
191 let calls = Cell::new(0);
192 let result: Result<u32> = run_with_retry(Some(&fast()), || {
193 calls.set(calls.get() + 1);
194 async move { Err(Error::InvalidRequest("nope".to_string())) }
195 })
196 .await;
197
198 assert!(result.is_err());
199 assert_eq!(calls.get(), 1, "non-retriable errors are not retried");
200 }
201
202 #[tokio::test]
203 async fn gives_up_after_max_attempts() {
204 let calls = Cell::new(0);
205 let result: Result<u32> = run_with_retry(Some(&fast()), || {
206 calls.set(calls.get() + 1);
207 async move { Err(Error::Timeout) }
208 })
209 .await;
210
211 assert!(result.is_err());
212 assert_eq!(calls.get(), 3, "stops at max_attempts");
213 }
214
215 #[tokio::test]
216 async fn attempt_timeout_bounds_a_hung_attempt() {
217 let calls = Cell::new(0);
218 let config = fast()
219 .with_max_attempts(2)
220 .with_attempt_timeout(Duration::from_millis(5));
221 let result: Result<u32> = run_with_retry(Some(&config), || {
222 calls.set(calls.get() + 1);
223 async move {
224 tokio::time::sleep(Duration::from_secs(30)).await;
227 Ok(1)
228 }
229 })
230 .await;
231
232 assert!(result.is_err(), "every attempt times out");
233 assert_eq!(calls.get(), 2, "the hung attempt is bounded and retried");
234 }
235
236 #[tokio::test]
237 async fn without_config_runs_exactly_once() {
238 let calls = Cell::new(0);
239 let result: Result<u32> = run_with_retry(None, || {
240 calls.set(calls.get() + 1);
241 async move { Err(Error::Timeout) }
242 })
243 .await;
244
245 assert!(result.is_err());
246 assert_eq!(calls.get(), 1);
247 }
248}