Skip to main content

rightkit_http/
retry.rs

1use std::time::{Duration, SystemTime, UNIX_EPOCH};
2
3/// Retry/backoff policy. `max_attempts` counts total tries (1 = no retry).
4#[derive(Debug, Clone, PartialEq)]
5pub struct RetryPolicy {
6    pub max_attempts: u32,
7    pub initial_backoff: Duration,
8    pub max_backoff: Duration,
9    pub multiplier: f64,
10    /// Fraction (0.0..=1.0) of each delay randomly shaved off; 0 = deterministic.
11    pub jitter: f64,
12    pub retry_statuses: Vec<u16>,
13    pub honor_retry_after: bool,
14    /// Upper bound applied to a server-supplied Retry-After.
15    pub max_retry_after: Duration,
16    /// When a server-supplied Retry-After is longer than `max_retry_after`, stop retrying
17    /// instead of sleeping the capped time and asking again too early. The call then returns
18    /// the response (or `HttpError::Status` with the full, uncapped `retry_after`) so the
19    /// caller can schedule the next try durably, for example with `QuotaSchedule`.
20    /// Default `false` (the old behaviour: sleep the capped delay and retry).
21    pub defer_over_cap: bool,
22}
23
24impl Default for RetryPolicy {
25    fn default() -> Self {
26        Self {
27            max_attempts: 3,
28            initial_backoff: Duration::from_millis(500),
29            max_backoff: Duration::from_secs(8),
30            multiplier: 2.0,
31            jitter: 0.2,
32            retry_statuses: vec![408, 425, 429, 500, 502, 503, 504],
33            honor_retry_after: true,
34            max_retry_after: Duration::from_secs(30),
35            defer_over_cap: false,
36        }
37    }
38}
39
40impl RetryPolicy {
41    /// True when `retry_after` is honoured, `defer_over_cap` is set and the server asked
42    /// for longer than `max_retry_after`: the retry loop should return, not sleep.
43    pub fn defers(&self, retry_after: Option<Duration>) -> bool {
44        self.defer_over_cap
45            && self.honor_retry_after
46            && retry_after.is_some_and(|ra| ra > self.max_retry_after)
47    }
48
49    pub fn none() -> Self {
50        Self {
51            max_attempts: 1,
52            ..Self::default()
53        }
54    }
55
56    pub fn is_retryable_status(&self, status: u16) -> bool {
57        self.retry_statuses.contains(&status)
58    }
59
60    /// Delay before retry number `retry` (1 = after the first failure).
61    pub fn backoff(&self, retry: u32) -> Duration {
62        let exp = retry.saturating_sub(1).min(32) as i32;
63        let secs = self.initial_backoff.as_secs_f64() * self.multiplier.powi(exp);
64        let capped = secs.min(self.max_backoff.as_secs_f64());
65        let shaved = if self.jitter > 0.0 {
66            capped * (1.0 - self.jitter.clamp(0.0, 1.0) * random_unit())
67        } else {
68            capped
69        };
70        Duration::try_from_secs_f64(shaved.max(0.0)).unwrap_or(self.max_backoff)
71    }
72
73    /// Delay for a given failure: the larger of backoff and a (capped) Retry-After.
74    pub fn delay(&self, retry: u32, retry_after: Option<Duration>) -> Duration {
75        let base = self.backoff(retry);
76        match (self.honor_retry_after, retry_after) {
77            (true, Some(ra)) => base.max(ra.min(self.max_retry_after)),
78            _ => base,
79        }
80    }
81}
82
83fn random_unit() -> f64 {
84    let mut b = [0u8; 4];
85    if getrandom::fill(&mut b).is_err() {
86        return 0.0;
87    }
88    u32::from_le_bytes(b) as f64 / u32::MAX as f64
89}
90
91/// Parse a Retry-After header: delta-seconds or an HTTP-date (RFC 9110 section 10.2.3).
92/// A date is turned into a delay from the current clock; a date in the past is zero.
93pub fn parse_retry_after(value: &str) -> Option<Duration> {
94    parse_retry_after_at(value, SystemTime::now())
95}
96
97/// [`parse_retry_after`] against an explicit `now` (for tests and callers with their own clock).
98pub fn parse_retry_after_at(value: &str, now: SystemTime) -> Option<Duration> {
99    let v = value.trim();
100    if let Ok(s) = v.parse::<f64>() {
101        return Some(s)
102            .filter(|s| s.is_finite() && *s >= 0.0)
103            .map(|s| Duration::try_from_secs_f64(s).unwrap_or(Duration::MAX));
104    }
105    let at = parse_http_date(v)?;
106    Some(at.duration_since(now).unwrap_or(Duration::ZERO))
107}
108
109/// Parse a rate-limit reset value (`X-RateLimit-Reset` and similar) into the wait it names,
110/// measured from `now`. Accepted forms: whole Unix epoch seconds (`1760000000`) and an ISO-8601
111/// instant with `T` (or a space) and either `Z` or a `±HH:MM` offset, fractional seconds
112/// allowed (`2026-10-18T10:00:00Z`). A reset already past is `Duration::ZERO`; any other text
113/// is `None`. A bare small integer is read as epoch seconds, so a delta-seconds value is not
114/// accepted here; use [`parse_retry_after_at`] for those.
115pub fn parse_rate_limit_reset_at(value: &str, now: SystemTime) -> Option<Duration> {
116    let v = value.trim();
117    let at = if !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()) {
118        let secs: u64 = v.parse().ok()?;
119        UNIX_EPOCH.checked_add(Duration::from_secs(secs))?
120    } else {
121        parse_iso8601_instant(v)?
122    };
123    Some(at.duration_since(now).unwrap_or(Duration::ZERO))
124}
125
126/// [`parse_rate_limit_reset_at`] against the current clock.
127pub fn parse_rate_limit_reset(value: &str) -> Option<Duration> {
128    parse_rate_limit_reset_at(value, SystemTime::now())
129}
130
131/// `YYYY-MM-DD[T ]HH:MM:SS[.fraction](Z|±HH:MM)`, fraction ignored.
132fn parse_iso8601_instant(v: &str) -> Option<SystemTime> {
133    let b = v.as_bytes();
134    if b.len() < 20 {
135        return None;
136    }
137    let num = |s: Option<&str>| -> Option<i64> {
138        let s = s?;
139        if !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit()) {
140            s.parse().ok()
141        } else {
142            None
143        }
144    };
145    if b[4] != b'-' || b[7] != b'-' || !matches!(b[10], b'T' | b't' | b' ') {
146        return None;
147    }
148    if b[13] != b':' || b[16] != b':' {
149        return None;
150    }
151    let year = num(v.get(0..4))?;
152    let month = num(v.get(5..7))?;
153    let day = num(v.get(8..10))?;
154    let hour = num(v.get(11..13))?;
155    let minute = num(v.get(14..16))?;
156    let second = num(v.get(17..19))?;
157    if !(1..=12).contains(&month)
158        || !(1..=31).contains(&day)
159        || hour > 23
160        || minute > 59
161        || second > 60
162    {
163        return None;
164    }
165    let mut i = 19;
166    if b.get(i) == Some(&b'.') {
167        i += 1;
168        let start = i;
169        while b.get(i).is_some_and(|c| c.is_ascii_digit()) {
170            i += 1;
171        }
172        if i == start {
173            return None;
174        }
175    }
176    let offset: i64 = match *b.get(i)? {
177        b'Z' | b'z' => {
178            i += 1;
179            0
180        }
181        sign @ (b'+' | b'-') => {
182            if b.get(i + 3) != Some(&b':') {
183                return None;
184            }
185            let oh = num(v.get(i + 1..i + 3))?;
186            let om = num(v.get(i + 4..i + 6))?;
187            i += 6;
188            let secs = oh * 3600 + om * 60;
189            if sign == b'-' {
190                -secs
191            } else {
192                secs
193            }
194        }
195        _ => return None,
196    };
197    if i != b.len() {
198        return None;
199    }
200    let days = days_from_civil(year, month, day);
201    let secs = days * 86_400 + hour * 3_600 + minute * 60 + second - offset;
202    if secs < 0 {
203        return None;
204    }
205    Some(UNIX_EPOCH + Duration::from_secs(secs as u64))
206}
207
208fn month_number(m: &str) -> Option<i64> {
209    const MONTHS: [&str; 12] = [
210        "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
211    ];
212    let m = m.get(..3)?.to_ascii_lowercase();
213    MONTHS.iter().position(|x| *x == m).map(|i| i as i64 + 1)
214}
215
216fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
217    let y = if m <= 2 { y - 1 } else { y };
218    let era = y.div_euclid(400);
219    let yoe = y - era * 400;
220    let mp = (m + 9) % 12;
221    let doy = (153 * mp + 2) / 5 + d - 1;
222    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
223    era * 146_097 + doe - 719_468
224}
225
226/// HTTP-date in the three RFC 9110 forms (IMF-fixdate, obsolete RFC 850, asctime), always GMT.
227pub(crate) fn parse_http_date(v: &str) -> Option<SystemTime> {
228    let t: Vec<&str> = v.split_whitespace().collect();
229    let (day, mon, year, time) = match t.len() {
230        6 if t[0].ends_with(',') && t[5].eq_ignore_ascii_case("GMT") => {
231            (t[1], t[2], t[3].parse::<i64>().ok()?, t[4])
232        }
233        4 if t[0].ends_with(',') && t[3].eq_ignore_ascii_case("GMT") => {
234            let mut p = t[1].split('-');
235            let (d, m, y) = (p.next()?, p.next()?, p.next()?);
236            if p.next().is_some() {
237                return None;
238            }
239            let yy = y.parse::<i64>().ok()?;
240            let year = if y.len() == 2 {
241                if yy < 70 {
242                    2000 + yy
243                } else {
244                    1900 + yy
245                }
246            } else {
247                yy
248            };
249            (d, m, year, t[2])
250        }
251        5 => (t[2], t[1], t[4].parse::<i64>().ok()?, t[3]),
252        _ => return None,
253    };
254    let day = day.parse::<i64>().ok()?;
255    let month = month_number(mon)?;
256    let mut hms = time.split(':');
257    let h = hms.next()?.parse::<i64>().ok()?;
258    let mi = hms.next()?.parse::<i64>().ok()?;
259    let s = hms.next()?.parse::<i64>().ok()?;
260    if hms.next().is_some()
261        || !(1..=31).contains(&day)
262        || !(0..24).contains(&h)
263        || !(0..60).contains(&mi)
264        || !(0..=60).contains(&s)
265        || !(1970..=9999).contains(&year)
266    {
267        return None;
268    }
269    let secs = days_from_civil(year, month, day) * 86_400 + h * 3600 + mi * 60 + s;
270    u64::try_from(secs)
271        .ok()
272        .map(|s| UNIX_EPOCH + Duration::from_secs(s))
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn huge_retry_after_saturates_instead_of_panicking() {
281        for v in [
282            "18446744073709551616",
283            "1e300",
284            "99999999999999999999999999999999999999",
285            "1.7976931348623157e308",
286        ] {
287            assert_eq!(parse_retry_after(v), Some(Duration::MAX), "{v}");
288        }
289        assert_eq!(parse_retry_after("1.5"), Some(Duration::from_millis(1500)));
290        assert_eq!(parse_retry_after("-1"), None);
291        assert_eq!(parse_retry_after("NaN"), None);
292        assert_eq!(parse_retry_after("inf"), None);
293    }
294
295    #[test]
296    fn delay_caps_a_saturated_retry_after() {
297        let p = RetryPolicy {
298            max_retry_after: Duration::from_secs(30),
299            jitter: 0.0,
300            ..RetryPolicy::default()
301        };
302        assert_eq!(p.delay(1, Some(Duration::MAX)), Duration::from_secs(30));
303    }
304
305    #[test]
306    fn backoff_with_absurd_settings_does_not_panic() {
307        let p = RetryPolicy {
308            initial_backoff: Duration::MAX,
309            max_backoff: Duration::MAX,
310            multiplier: f64::MAX,
311            jitter: 0.0,
312            ..RetryPolicy::default()
313        };
314        let _ = p.backoff(40);
315    }
316
317    #[test]
318    fn retry_after_http_date_forms() {
319        let at = UNIX_EPOCH + Duration::from_secs(784_111_777); // Sun, 06 Nov 1994 08:49:37 GMT
320        let now = at - Duration::from_secs(90);
321        for v in [
322            "Sun, 06 Nov 1994 08:49:37 GMT",
323            "Sunday, 06-Nov-94 08:49:37 GMT",
324            "Sun Nov  6 08:49:37 1994",
325        ] {
326            assert_eq!(
327                parse_retry_after_at(v, now),
328                Some(Duration::from_secs(90)),
329                "{v}"
330            );
331        }
332        // A date in the past is a zero delay; junk is None.
333        assert_eq!(
334            parse_retry_after_at("Sun, 06 Nov 1994 08:49:37 GMT", at + Duration::from_secs(5)),
335            Some(Duration::ZERO)
336        );
337        assert_eq!(parse_retry_after_at("soon", now), None);
338        assert_eq!(
339            parse_retry_after_at("Sun, 32 Nov 1994 08:49:37 GMT", now),
340            None
341        );
342    }
343
344    #[test]
345    fn retry_after_beyond_the_cap_is_kept_whole_and_defers_on_request() {
346        assert_eq!(parse_retry_after("3600"), Some(Duration::from_secs(3600)));
347        let mut p = RetryPolicy::default();
348        assert!(!p.defers(Some(Duration::from_secs(3600))));
349        p.defer_over_cap = true;
350        assert!(p.defers(Some(Duration::from_secs(3600))));
351        assert!(!p.defers(Some(Duration::from_secs(10))));
352        assert!(!p.defers(None));
353        p.honor_retry_after = false;
354        assert!(!p.defers(Some(Duration::from_secs(3600))));
355    }
356
357    #[test]
358    fn rate_limit_reset_epoch_and_iso_forms() {
359        // 2026-10-18T10:00:00Z is 1792317600 seconds after the epoch.
360        let now = UNIX_EPOCH + Duration::from_secs(1_792_317_600 - 90);
361        let want = Some(Duration::from_secs(90));
362        assert_eq!(parse_rate_limit_reset_at("1792317600", now), want);
363        assert_eq!(parse_rate_limit_reset_at("2026-10-18T10:00:00Z", now), want);
364        assert_eq!(
365            parse_rate_limit_reset_at("2026-10-18T12:00:00+02:00", now),
366            want
367        );
368        assert_eq!(
369            parse_rate_limit_reset_at("2026-10-18 10:00:00.250Z", now),
370            Some(Duration::from_secs(90))
371        );
372        assert_eq!(
373            parse_rate_limit_reset_at("2026-10-18T10:00:00-02:00", now),
374            Some(Duration::from_secs(90 + 7200))
375        );
376        assert_eq!(parse_rate_limit_reset_at("1000", now), Some(Duration::ZERO));
377        assert_eq!(parse_rate_limit_reset_at("2026-13-18T10:00:00Z", now), None);
378        assert_eq!(parse_rate_limit_reset_at("2026-10-18T10:00:00", now), None);
379        assert_eq!(parse_rate_limit_reset_at("soon", now), None);
380        assert_eq!(parse_rate_limit_reset_at("", now), None);
381    }
382}