1use crate::util::errors::{GitCliError, HttpNotSuccessful};
45use crate::{CargoResult, GlobalContext};
46use anyhow::Error;
47use rand::Rng;
48use std::cmp::min;
49use std::time::Duration;
50
51pub struct Retry<'a> {
53 gctx: &'a GlobalContext,
54 retries: u64,
58 max_retries: u64,
62}
63
64pub enum RetryResult<T> {
66 Success(T),
70 Err(anyhow::Error),
72 Retry(u64),
78}
79
80const MAX_RETRY_SLEEP_MS: u64 = 10 * 1000;
82const INITIAL_RETRY_SLEEP_BASE_MS: u64 = 500;
86const INITIAL_RETRY_JITTER_MS: u64 = 1000;
91
92impl<'a> Retry<'a> {
93 pub fn new(gctx: &'a GlobalContext) -> CargoResult<Retry<'a>> {
94 Ok(Retry {
95 gctx,
96 retries: 0,
97 max_retries: gctx.net_config()?.retry.unwrap_or(3) as u64,
98 })
99 }
100
101 pub fn r#try<T>(&mut self, f: impl FnOnce() -> CargoResult<T>) -> RetryResult<T> {
105 match f() {
106 Err(ref e) if maybe_spurious(e) && self.retries < self.max_retries => {
107 let err = e.downcast_ref::<HttpNotSuccessful>();
108 let err_msg = err
109 .map(|http_err| http_err.display_short())
110 .unwrap_or_else(|| e.root_cause().to_string());
111 let left_retries = self.max_retries - self.retries;
112 let msg = format!(
113 "spurious network error ({} {} remaining): {err_msg}",
114 left_retries,
115 if left_retries != 1 { "tries" } else { "try" }
116 );
117 if let Err(e) = self.gctx.shell().warn(msg) {
118 return RetryResult::Err(e);
119 }
120 self.retries += 1;
121 let sleep = err
122 .and_then(|v| Self::parse_retry_after(v, &jiff::Timestamp::now()))
123 .map(|retry_after| retry_after.min(MAX_RETRY_SLEEP_MS))
125 .unwrap_or_else(|| self.next_sleep_ms());
126 RetryResult::Retry(sleep)
127 }
128 Err(e) => RetryResult::Err(e),
129 Ok(r) => RetryResult::Success(r),
130 }
131 }
132
133 fn next_sleep_ms(&self) -> u64 {
135 if let Ok(sleep) = self.gctx.get_env("__CARGO_TEST_FIXED_RETRY_SLEEP_MS") {
136 return sleep.parse().expect("a u64");
137 }
138
139 if self.retries == 1 {
140 let mut rng = rand::rng();
141 INITIAL_RETRY_SLEEP_BASE_MS + rng.random_range(0..INITIAL_RETRY_JITTER_MS)
142 } else {
143 min(
144 ((self.retries - 1) * 3) * 1000 + INITIAL_RETRY_SLEEP_BASE_MS,
145 MAX_RETRY_SLEEP_MS,
146 )
147 }
148 }
149
150 fn parse_retry_after(response: &HttpNotSuccessful, now: &jiff::Timestamp) -> Option<u64> {
153 if !matches!(response.code, 429 | 503) {
155 return None;
156 }
157
158 let retry_after = response
160 .headers
161 .iter()
162 .filter_map(|h| h.split_once(':'))
163 .map(|(k, v)| (k.trim(), v.trim()))
164 .find(|(k, _)| k.eq_ignore_ascii_case("retry-after"))?
165 .1;
166
167 if let Ok(delay_secs) = retry_after.parse::<u32>() {
169 return Some(delay_secs as u64 * 1000);
170 }
171
172 if let Ok(retry_time) = jiff::fmt::rfc2822::parse(retry_after) {
174 let diff_ms = now
175 .until(&retry_time)
176 .unwrap()
177 .total(jiff::Unit::Millisecond)
178 .unwrap();
179 if diff_ms > 0.0 {
180 return Some(diff_ms as u64);
181 }
182 }
183 None
184 }
185}
186
187fn maybe_spurious(err: &Error) -> bool {
188 if let Some(git_err) = err.downcast_ref::<git2::Error>() {
189 match git_err.class() {
190 git2::ErrorClass::Net
191 | git2::ErrorClass::Os
192 | git2::ErrorClass::Zlib
193 | git2::ErrorClass::Http => return git_err.code() != git2::ErrorCode::Certificate,
194 _ => (),
195 }
196 }
197 if let Some(curl_err) = err.downcast_ref::<curl::Error>() {
198 if curl_err.is_couldnt_connect()
199 || curl_err.is_couldnt_resolve_proxy()
200 || curl_err.is_couldnt_resolve_host()
201 || curl_err.is_operation_timedout()
202 || curl_err.is_recv_error()
203 || curl_err.is_send_error()
204 || curl_err.is_http2_error()
205 || curl_err.is_http2_stream_error()
206 || curl_err.is_ssl_connect_error()
207 || curl_err.is_partial_file()
208 {
209 return true;
210 }
211 }
212 if let Some(not_200) = err.downcast_ref::<HttpNotSuccessful>() {
213 if 500 <= not_200.code && not_200.code < 600 || not_200.code == 429 {
214 return true;
215 }
216 }
217
218 use gix::protocol::transport::IsSpuriousError;
219
220 if let Some(err) = err.downcast_ref::<crate::sources::git::fetch::Error>() {
221 if err.is_spurious() {
222 return true;
223 }
224 }
225
226 if let Some(err) = err.downcast_ref::<GitCliError>() {
227 if err.is_spurious() {
228 return true;
229 }
230 }
231
232 false
233}
234
235pub fn with_retry<T, F>(gctx: &GlobalContext, mut callback: F) -> CargoResult<T>
252where
253 F: FnMut() -> CargoResult<T>,
254{
255 let mut retry = Retry::new(gctx)?;
256 loop {
257 match retry.r#try(&mut callback) {
258 RetryResult::Success(r) => return Ok(r),
259 RetryResult::Err(e) => return Err(e),
260 RetryResult::Retry(sleep) => std::thread::sleep(Duration::from_millis(sleep)),
261 }
262 }
263}
264
265#[test]
266fn with_retry_repeats_the_call_then_works() {
267 use crate::core::Shell;
268
269 let error1 = HttpNotSuccessful {
271 code: 501,
272 url: "Uri".to_string(),
273 ip: None,
274 body: Vec::new(),
275 headers: Vec::new(),
276 }
277 .into();
278 let error2 = HttpNotSuccessful {
279 code: 502,
280 url: "Uri".to_string(),
281 ip: None,
282 body: Vec::new(),
283 headers: Vec::new(),
284 }
285 .into();
286 let mut results: Vec<CargoResult<()>> = vec![Ok(()), Err(error1), Err(error2)];
287 let gctx = GlobalContext::default().unwrap();
288 *gctx.shell() = Shell::from_write(Box::new(Vec::new()));
289 let result = with_retry(&gctx, || results.pop().unwrap());
290 assert!(result.is_ok())
291}
292
293#[test]
294fn with_retry_finds_nested_spurious_errors() {
295 use crate::core::Shell;
296
297 let error1 = anyhow::Error::from(HttpNotSuccessful {
300 code: 501,
301 url: "Uri".to_string(),
302 ip: None,
303 body: Vec::new(),
304 headers: Vec::new(),
305 });
306 let error1 = anyhow::Error::from(error1.context("A non-spurious wrapping err"));
307 let error2 = anyhow::Error::from(HttpNotSuccessful {
308 code: 502,
309 url: "Uri".to_string(),
310 ip: None,
311 body: Vec::new(),
312 headers: Vec::new(),
313 });
314 let error2 = anyhow::Error::from(error2.context("A second chained error"));
315 let mut results: Vec<CargoResult<()>> = vec![Ok(()), Err(error1), Err(error2)];
316 let gctx = GlobalContext::default().unwrap();
317 *gctx.shell() = Shell::from_write(Box::new(Vec::new()));
318 let result = with_retry(&gctx, || results.pop().unwrap());
319 assert!(result.is_ok())
320}
321
322#[test]
323fn default_retry_schedule() {
324 use crate::core::Shell;
325
326 let spurious = || -> CargoResult<()> {
327 Err(anyhow::Error::from(HttpNotSuccessful {
328 code: 500,
329 url: "Uri".to_string(),
330 ip: None,
331 body: Vec::new(),
332 headers: Vec::new(),
333 }))
334 };
335 let gctx = GlobalContext::default().unwrap();
336 *gctx.shell() = Shell::from_write(Box::new(Vec::new()));
337 let mut retry = Retry::new(&gctx).unwrap();
338 match retry.r#try(|| spurious()) {
339 RetryResult::Retry(sleep) => {
340 assert!(
341 sleep >= INITIAL_RETRY_SLEEP_BASE_MS
342 && sleep < INITIAL_RETRY_SLEEP_BASE_MS + INITIAL_RETRY_JITTER_MS
343 );
344 }
345 _ => panic!("unexpected non-retry"),
346 }
347 match retry.r#try(|| spurious()) {
348 RetryResult::Retry(sleep) => assert_eq!(sleep, 3500),
349 _ => panic!("unexpected non-retry"),
350 }
351 match retry.r#try(|| spurious()) {
352 RetryResult::Retry(sleep) => assert_eq!(sleep, 6500),
353 _ => panic!("unexpected non-retry"),
354 }
355 match retry.r#try(|| spurious()) {
356 RetryResult::Err(_) => {}
357 _ => panic!("unexpected non-retry"),
358 }
359}
360
361#[test]
362fn curle_http2_stream_is_spurious() {
363 let code = curl_sys::CURLE_HTTP2_STREAM;
364 let err = curl::Error::new(code);
365 assert!(maybe_spurious(&err.into()));
366}
367
368#[test]
369fn retry_after_parsing() {
370 use crate::core::Shell;
371 fn spurious(code: u32, header: &str) -> HttpNotSuccessful {
372 HttpNotSuccessful {
373 code,
374 url: "Uri".to_string(),
375 ip: None,
376 body: Vec::new(),
377 headers: vec![header.to_string()],
378 }
379 }
380
381 let now = jiff::Timestamp::new(1735689600, 0).unwrap();
383 let headers = spurious(429, "Retry-After: 10");
384 assert_eq!(Retry::parse_retry_after(&headers, &now), Some(10_000));
385 let headers = spurious(429, "retry-after: Wed, 01 Jan 2025 00:00:10 GMT");
386 let actual = Retry::parse_retry_after(&headers, &now).unwrap();
387 assert_eq!(10000, actual);
388
389 let headers = spurious(429, "Content-Type: text/html");
390 assert_eq!(Retry::parse_retry_after(&headers, &now), None);
391
392 let headers = spurious(429, "retry-after: Fri, 01 Jan 2000 00:00:00 GMT");
393 assert_eq!(Retry::parse_retry_after(&headers, &now), None);
394
395 let headers = spurious(429, "retry-after: -1");
396 assert_eq!(Retry::parse_retry_after(&headers, &now), None);
397
398 let headers = spurious(400, "retry-after: 1");
399 assert_eq!(Retry::parse_retry_after(&headers, &now), None);
400
401 let gctx = GlobalContext::default().unwrap();
402 *gctx.shell() = Shell::from_write(Box::new(Vec::new()));
403 let mut retry = Retry::new(&gctx).unwrap();
404 match retry
405 .r#try(|| -> CargoResult<()> { Err(anyhow::Error::from(spurious(429, "Retry-After: 7"))) })
406 {
407 RetryResult::Retry(sleep) => assert_eq!(sleep, 7_000),
408 _ => panic!("unexpected non-retry"),
409 }
410}
411
412#[test]
413fn git_cli_error_spurious() {
414 let error = GitCliError::new(Error::msg("test-git-cli-error"), false);
415 assert!(!maybe_spurious(&error.into()));
416
417 let error = GitCliError::new(Error::msg("test-git-cli-error"), true);
418 assert!(maybe_spurious(&error.into()));
419}