summaryrefslogtreecommitdiffstats
path: root/src/retry.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/retry.rs')
-rw-r--r--src/retry.rs143
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);
+ }
+ }
+}