diff options
Diffstat (limited to 'src/retry.rs')
| -rw-r--r-- | src/retry.rs | 143 |
1 files changed, 143 insertions, 0 deletions
diff --git a/src/retry.rs b/src/retry.rs new file mode 100644 index 0000000..3e69ff0 --- /dev/null +++ b/src/retry.rs @@ -0,0 +1,143 @@ +//! Backoff arithmetic and `Retry-After` parsing. + +use std::collections::hash_map::RandomState; +use std::hash::{BuildHasher, Hasher}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// A random `Duration` in `[0, ceiling]` — *full jitter*. +/// +/// The randomness is not decoration: without it, N clients that fail together +/// come back together and reproduce the spike that took the service down. +/// +/// The source is the standard library's hash seed mixed with the current time, +/// which is plenty for spreading retries and is not suitable for anything +/// cryptographic. +pub(crate) fn jitter(ceiling: Duration) -> Duration { + let mut hasher = RandomState::new().build_hasher(); + hasher.write_u128( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(), + ); + let fraction = (hasher.finish() >> 11) as f64 / (1u64 << 53) as f64; + ceiling.mul_f64(fraction) +} + +/// Parse the `Retry-After` header in both RFC 9110 forms. +/// +/// It may arrive as a number of seconds (`"120"`) or as an HTTP date +/// (`"Wed, 21 Oct 2026 07:28:00 GMT"`). Handling only the first is the common +/// bug, and it makes a client ignore exactly the wait request that matters +/// most. A date already in the past yields `Duration::ZERO`; anything +/// unparseable yields `None`. +pub(crate) fn parse_retry_after(value: &str) -> Option<Duration> { + let value = value.trim(); + if value.is_empty() { + return None; + } + + if let Ok(seconds) = value.parse::<f64>() { + if seconds.is_finite() { + return Some(Duration::try_from_secs_f64(seconds.max(0.0)).unwrap_or(Duration::ZERO)); + } + } + + let target = http_date_to_unix(value)?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + Some(Duration::from_secs((target - now).max(0) as u64)) +} + +/// Seconds since the Unix epoch for an IMF-fixdate, the form HTTP servers must +/// send: `Wed, 21 Oct 2026 07:28:00 GMT`. The two obsolete formats RFC 9110 +/// still mentions are not accepted. +fn http_date_to_unix(value: &str) -> Option<i64> { + let rest = value.split_once(", ").map(|(_, r)| r).unwrap_or(value); + let mut parts = rest.split_whitespace(); + + let day: i64 = parts.next()?.parse().ok()?; + let month = month_number(parts.next()?)?; + let year: i64 = parts.next()?.parse().ok()?; + + let mut clock = parts.next()?.split(':'); + let hour: i64 = clock.next()?.parse().ok()?; + let minute: i64 = clock.next()?.parse().ok()?; + let second: i64 = clock.next()?.parse().ok()?; + + if !(1..=31).contains(&day) || hour > 23 || minute > 59 || second > 60 { + return None; + } + + Some(days_from_civil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second) +} + +fn month_number(name: &str) -> Option<i64> { + const MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + MONTHS.iter().position(|m| *m == name).map(|i| i as i64 + 1) +} + +/// Days between 1970-01-01 and the given proleptic Gregorian date. +fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { + let year = if month <= 2 { year - 1 } else { year }; + let era = if year >= 0 { year } else { year - 399 } / 400; + let year_of_era = year - era * 400; + let month_shifted = (month + 9) % 12; + let day_of_year = (153 * month_shifted + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + era * 146_097 + day_of_era - 719_468 +} + +#[cfg(test)] +mod tests { + use super::{days_from_civil, http_date_to_unix, jitter, parse_retry_after}; + use std::time::Duration; + + #[test] + fn reads_plain_seconds() { + assert_eq!(parse_retry_after(" 7 "), Some(Duration::from_secs(7))); + } + + #[test] + fn negative_seconds_become_zero() { + assert_eq!(parse_retry_after("-5"), Some(Duration::ZERO)); + } + + #[test] + fn reads_an_http_date() { + let epoch = http_date_to_unix("Wed, 21 Oct 2026 07:28:00 GMT").unwrap(); + assert_eq!(epoch, 1_792_567_680); + } + + #[test] + fn a_date_in_the_past_is_zero() { + assert_eq!( + parse_retry_after("Thu, 01 Jan 1970 00:00:00 GMT"), + Some(Duration::ZERO) + ); + } + + #[test] + fn rejects_nonsense() { + assert_eq!(parse_retry_after("soon"), None); + assert_eq!(parse_retry_after(""), None); + } + + #[test] + fn civil_days_match_known_dates() { + assert_eq!(days_from_civil(1970, 1, 1), 0); + assert_eq!(days_from_civil(2000, 3, 1), 11_017); + } + + #[test] + fn jitter_stays_within_the_ceiling() { + let ceiling = Duration::from_millis(50); + for _ in 0..100 { + assert!(jitter(ceiling) <= ceiling); + } + } +} |