thunderstore_engine/
retry.rs1#![deny(missing_docs)]
12
13use crate::error::{Error, Result};
14use std::future::Future;
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::time::Duration;
17
18#[derive(Debug, Clone, Copy)]
23pub struct RetryPolicy {
24 pub max_retries: u32,
27 pub base_delay: Duration,
29 pub max_delay: Duration,
31}
32
33impl Default for RetryPolicy {
34 fn default() -> Self {
35 Self {
36 max_retries: 3,
37 base_delay: Duration::from_millis(250),
38 max_delay: Duration::from_secs(10),
39 }
40 }
41}
42
43impl RetryPolicy {
44 pub fn none() -> Self {
46 Self {
47 max_retries: 0,
48 ..Self::default()
49 }
50 }
51
52 fn backoff(&self, attempt: u32) -> Duration {
54 let factor = 1u32.checked_shl(attempt).unwrap_or(u32::MAX);
55 let capped = self.base_delay.saturating_mul(factor).min(self.max_delay);
56 let jitter_ceiling_ms = (capped.as_millis() / 4) as u64;
57
58 if jitter_ceiling_ms == 0 {
59 return capped;
60 }
61
62 capped + Duration::from_millis(jitter_source() % (jitter_ceiling_ms + 1))
63 }
64}
65
66fn jitter_source() -> u64 {
69 static COUNTER: AtomicU64 = AtomicU64::new(0);
70
71 COUNTER.fetch_add(0x9E37_79B9_7F4A_7C15, Ordering::Relaxed)
72}
73
74pub(crate) async fn retry_with<T, F>(
87 policy: &RetryPolicy,
88 is_retryable: impl Fn(&Error) -> bool,
89 mut op: impl FnMut() -> F,
90) -> Result<T>
91where
92 F: Future<Output = Result<T>>,
93{
94 let mut attempt = 0u32;
95
96 loop {
97 match op().await {
98 Ok(value) => return Ok(value),
99 Err(err) => {
100 if attempt >= policy.max_retries || !is_retryable(&err) {
101 return Err(err);
102 }
103
104 let delay = policy.backoff(attempt);
105
106 tracing::warn!(
107 "transient network error ({}); retry {}/{}",
108 err,
109 attempt + 1,
110 policy.max_retries
111 );
112
113 if !delay.is_zero() {
114 tokio::time::sleep(delay).await;
115 }
116
117 attempt += 1;
118 }
119 }
120 }
121}
122
123pub(crate) async fn retry_http<T, F>(policy: &RetryPolicy, op: impl FnMut() -> F) -> Result<T>
125where
126 F: Future<Output = Result<T>>,
127{
128 retry_with(policy, is_retryable_http, op).await
129}
130
131pub(crate) fn is_retryable_http(err: &Error) -> bool {
135 match err {
136 Error::Http(e) => {
137 if let Some(status) = e.status() {
138 status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
139 } else {
140 e.is_timeout() || e.is_connect() || e.is_request() || e.is_body()
141 }
142 }
143 _ => false,
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use std::cell::Cell;
151
152 fn zero_delay(max_retries: u32) -> RetryPolicy {
153 RetryPolicy {
154 max_retries,
155 base_delay: Duration::ZERO,
156 max_delay: Duration::ZERO,
157 }
158 }
159
160 #[tokio::test]
161 async fn retries_until_success() {
162 let calls = Cell::new(0u32);
163
164 let result: Result<u32> = retry_with(
165 &zero_delay(3),
166 |_| true,
167 || async {
168 calls.set(calls.get() + 1);
169
170 if calls.get() < 3 {
171 Err(Error::Cache("transient".to_string()))
172 } else {
173 Ok(42)
174 }
175 },
176 )
177 .await;
178
179 assert_eq!(result.unwrap(), 42);
180 assert_eq!(calls.get(), 3);
181 }
182
183 #[tokio::test]
184 async fn gives_up_after_max_retries() {
185 let calls = Cell::new(0u32);
186
187 let result: Result<u32> = retry_with(
188 &zero_delay(2),
189 |_| true,
190 || async {
191 calls.set(calls.get() + 1);
192
193 Err(Error::Cache("always".to_string()))
194 },
195 )
196 .await;
197
198 assert!(result.is_err());
199 assert_eq!(calls.get(), 3); }
201
202 #[tokio::test]
203 async fn does_not_retry_non_retryable() {
204 let calls = Cell::new(0u32);
205
206 let result: Result<u32> = retry_with(
207 &zero_delay(5),
208 |_| false,
209 || async {
210 calls.set(calls.get() + 1);
211
212 Err(Error::Cache("terminal".to_string()))
213 },
214 )
215 .await;
216
217 assert!(result.is_err());
218 assert_eq!(calls.get(), 1);
219 }
220
221 #[test]
222 fn crate_terminal_errors_are_not_retryable() {
223 assert!(!is_retryable_http(&Error::SizeLimitExceeded {
224 context: "download body".to_string(),
225 limit: 1,
226 actual: None,
227 }));
228 assert!(!is_retryable_http(&Error::Cache("bad cache".to_string())));
229 assert!(!is_retryable_http(&Error::ChecksumMismatch {
230 path: "x".to_string(),
231 expected: 1,
232 actual: 2,
233 }));
234 }
235}