summaryrefslogtreecommitdiffstats
path: root/src/retry.rs
blob: 3e69ff087089f8fec962950859ea642223f53207 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
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);
        }
    }
}