Skip to content

Commit b53cfdf

Browse files
committed
feat(network): use Retry-After header for HTTP 429 responses
1 parent ab15d58 commit b53cfdf

File tree

1 file changed

+92
-4
lines changed

1 file changed

+92
-4
lines changed

src/cargo/util/network/retry.rs

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ use anyhow::Error;
4747
use rand::Rng;
4848
use std::cmp::min;
4949
use std::time::Duration;
50+
use time::OffsetDateTime;
5051

5152
/// State for managing retrying a network operation.
5253
pub struct Retry<'a> {
@@ -104,8 +105,8 @@ impl<'a> Retry<'a> {
104105
pub fn r#try<T>(&mut self, f: impl FnOnce() -> CargoResult<T>) -> RetryResult<T> {
105106
match f() {
106107
Err(ref e) if maybe_spurious(e) && self.retries < self.max_retries => {
107-
let err_msg = e
108-
.downcast_ref::<HttpNotSuccessful>()
108+
let err = e.downcast_ref::<HttpNotSuccessful>();
109+
let err_msg = err
109110
.map(|http_err| http_err.display_short())
110111
.unwrap_or_else(|| e.root_cause().to_string());
111112
let left_retries = self.max_retries - self.retries;
@@ -118,7 +119,12 @@ impl<'a> Retry<'a> {
118119
return RetryResult::Err(e);
119120
}
120121
self.retries += 1;
121-
RetryResult::Retry(self.next_sleep_ms())
122+
let sleep = err
123+
.and_then(Self::parse_retry_after)
124+
// Limit the Retry-After to a maximum value to avoid waiting too long.
125+
.map(|retry_after| retry_after.min(MAX_RETRY_SLEEP_MS))
126+
.unwrap_or_else(|| self.next_sleep_ms());
127+
RetryResult::Retry(sleep)
122128
}
123129
Err(e) => RetryResult::Err(e),
124130
Ok(r) => RetryResult::Success(r),
@@ -141,6 +147,40 @@ impl<'a> Retry<'a> {
141147
)
142148
}
143149
}
150+
151+
/// Parse the HTTP `Retry-After` header.
152+
/// Returns the number of milliseconds to wait before retrying according to the header.
153+
fn parse_retry_after(response: &HttpNotSuccessful) -> Option<u64> {
154+
// Only applies to HTTP 429 (too many requests) and 503 (service unavailable).
155+
if !matches!(response.code, 429 | 503) {
156+
return None;
157+
}
158+
159+
// Extract the Retry-After header value.
160+
let retry_after = response
161+
.headers
162+
.iter()
163+
.filter_map(|h| h.split_once(':'))
164+
.map(|(k, v)| (k.trim(), v.trim()))
165+
.find(|(k, _)| k.eq_ignore_ascii_case("retry-after"))?
166+
.1;
167+
168+
// First option: Retry-After is a positive integer of seconds to wait.
169+
if let Ok(delay_secs) = retry_after.parse::<u32>() {
170+
return Some(delay_secs as u64 * 1000);
171+
}
172+
173+
// Second option: Retry-After is a future HTTP date string that tells us when to retry.
174+
if let Ok(retry_time) =
175+
OffsetDateTime::parse(retry_after, &time::format_description::well_known::Rfc2822)
176+
{
177+
let now = OffsetDateTime::now_utc();
178+
if retry_time > now {
179+
return Some((retry_time - now).whole_milliseconds() as u64);
180+
}
181+
}
182+
None
183+
}
144184
}
145185

146186
fn maybe_spurious(err: &Error) -> bool {
@@ -169,7 +209,7 @@ fn maybe_spurious(err: &Error) -> bool {
169209
}
170210
}
171211
if let Some(not_200) = err.downcast_ref::<HttpNotSuccessful>() {
172-
if 500 <= not_200.code && not_200.code < 600 {
212+
if 500 <= not_200.code && not_200.code < 600 || not_200.code == 429 {
173213
return true;
174214
}
175215
}
@@ -317,3 +357,51 @@ fn curle_http2_stream_is_spurious() {
317357
let err = curl::Error::new(code);
318358
assert!(maybe_spurious(&err.into()));
319359
}
360+
361+
#[test]
362+
fn retry_after_parsing() {
363+
use crate::core::Shell;
364+
fn spurious(code: u32, header: &str) -> HttpNotSuccessful {
365+
HttpNotSuccessful {
366+
code,
367+
url: "Uri".to_string(),
368+
ip: None,
369+
body: Vec::new(),
370+
headers: vec![header.to_string()],
371+
}
372+
}
373+
374+
let headers = spurious(429, "Retry-After: 10");
375+
assert_eq!(Retry::parse_retry_after(&headers), Some(10_000));
376+
377+
let headers = spurious(429, "retry-after: Fri, 01 Jan 2100 00:00:00 GMT");
378+
let expected = (OffsetDateTime::new_utc(
379+
time::Date::from_calendar_date(2100, time::Month::January, 1).unwrap(),
380+
time::Time::from_hms(0, 0, 0).unwrap(),
381+
) - OffsetDateTime::now_utc())
382+
.whole_milliseconds();
383+
let actual = Retry::parse_retry_after(&headers).unwrap();
384+
assert!((expected.abs_diff(actual.into()) < 1000));
385+
386+
let headers = spurious(429, "Content-Type: text/html");
387+
assert_eq!(Retry::parse_retry_after(&headers), None);
388+
389+
let headers = spurious(429, "retry-after: Fri, 01 Jan 2000 00:00:00 GMT");
390+
assert_eq!(Retry::parse_retry_after(&headers), None);
391+
392+
let headers = spurious(429, "retry-after: -1");
393+
assert_eq!(Retry::parse_retry_after(&headers), None);
394+
395+
let headers = spurious(400, "retry-after: 1");
396+
assert_eq!(Retry::parse_retry_after(&headers), None);
397+
398+
let gctx = GlobalContext::default().unwrap();
399+
*gctx.shell() = Shell::from_write(Box::new(Vec::new()));
400+
let mut retry = Retry::new(&gctx).unwrap();
401+
match retry
402+
.r#try(|| -> CargoResult<()> { Err(anyhow::Error::from(spurious(429, "Retry-After: 7"))) })
403+
{
404+
RetryResult::Retry(sleep) => assert_eq!(sleep, 7_000),
405+
_ => panic!("unexpected non-retry"),
406+
}
407+
}

0 commit comments

Comments
 (0)