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    /// Opt-in separate budget for transport/timeout failures: the most attempts that may
23    /// end in such a failure (`1` = the first transport failure is final). `None` (the
24    /// default) keeps the legacy single shared counter, where only `max_attempts` binds.
25    /// When either budget is set, failures count only against their own budget, backoff
26    /// advances per budget, alternating failures never reset either counter, and
27    /// `max_attempts` remains the finite cap on total physical attempts.
28    pub max_transport_attempts: Option<u32>,
29    /// Opt-in separate budget for retryable-status failures; see
30    /// [`RetryPolicy::max_transport_attempts`].
31    pub max_status_attempts: Option<u32>,
32}
33
34impl Default for RetryPolicy {
35    fn default() -> Self {
36        Self {
37            max_attempts: 3,
38            initial_backoff: Duration::from_millis(500),
39            max_backoff: Duration::from_secs(8),
40            multiplier: 2.0,
41            jitter: 0.2,
42            retry_statuses: vec![408, 425, 429, 500, 502, 503, 504],
43            honor_retry_after: true,
44            max_retry_after: Duration::from_secs(30),
45            defer_over_cap: false,
46            max_transport_attempts: None,
47            max_status_attempts: None,
48        }
49    }
50}
51
52impl RetryPolicy {
53    /// True when `retry_after` is honoured, `defer_over_cap` is set and the server asked
54    /// for longer than `max_retry_after`: the retry loop should return, not sleep.
55    pub fn defers(&self, retry_after: Option<Duration>) -> bool {
56        self.defer_over_cap
57            && self.honor_retry_after
58            && retry_after.is_some_and(|ra| ra > self.max_retry_after)
59    }
60
61    pub fn none() -> Self {
62        Self {
63            max_attempts: 1,
64            ..Self::default()
65        }
66    }
67
68    pub fn is_retryable_status(&self, status: u16) -> bool {
69        self.retry_statuses.contains(&status)
70    }
71
72    /// True when either opt-in per-failure-kind budget is set; see
73    /// [`RetryPolicy::max_transport_attempts`].
74    pub fn separate_budgets(&self) -> bool {
75        self.max_transport_attempts.is_some() || self.max_status_attempts.is_some()
76    }
77
78    /// Delay before retry number `retry` (1 = after the first failure).
79    pub fn backoff(&self, retry: u32) -> Duration {
80        let exp = retry.saturating_sub(1).min(32) as i32;
81        let secs = self.initial_backoff.as_secs_f64() * self.multiplier.powi(exp);
82        let capped = secs.min(self.max_backoff.as_secs_f64());
83        let shaved = if self.jitter > 0.0 {
84            capped * (1.0 - self.jitter.clamp(0.0, 1.0) * random_unit())
85        } else {
86            capped
87        };
88        Duration::try_from_secs_f64(shaved.max(0.0)).unwrap_or(self.max_backoff)
89    }
90
91    /// Delay for a given failure: the larger of backoff and a (capped) Retry-After.
92    pub fn delay(&self, retry: u32, retry_after: Option<Duration>) -> Duration {
93        let base = self.backoff(retry);
94        match (self.honor_retry_after, retry_after) {
95            (true, Some(ra)) => base.max(ra.min(self.max_retry_after)),
96            _ => base,
97        }
98    }
99}
100
101/// Per-call failure counters behind [`RetryPolicy::max_transport_attempts`]. One instance
102/// lives for a whole call across both clients' retry loops. A recorded failure returns the
103/// backoff index when another attempt may run: the per-kind counter under separate
104/// budgets, the physical attempt number under the legacy shared counter. The total
105/// physical cap (`max_attempts`) always binds, so alternating failure kinds stay finite
106/// and neither counter ever resets.
107pub(crate) struct AttemptBudgets {
108    transport: u32,
109    status: u32,
110}
111
112impl AttemptBudgets {
113    pub(crate) fn new() -> Self {
114        Self {
115            transport: 0,
116            status: 0,
117        }
118    }
119
120    fn may_retry(
121        &self,
122        policy: &RetryPolicy,
123        attempt: u32,
124        failures: u32,
125        budget: Option<u32>,
126    ) -> Option<u32> {
127        if attempt >= policy.max_attempts.max(1) {
128            return None;
129        }
130        if budget.is_some_and(|b| failures >= b) {
131            return None;
132        }
133        Some(if policy.separate_budgets() {
134            failures
135        } else {
136            attempt
137        })
138    }
139
140    /// Record a transport/timeout failure of physical attempt `attempt` (1-based).
141    pub(crate) fn transport_failed(&mut self, policy: &RetryPolicy, attempt: u32) -> Option<u32> {
142        self.transport = self.transport.saturating_add(1);
143        self.may_retry(
144            policy,
145            attempt,
146            self.transport,
147            policy.max_transport_attempts,
148        )
149    }
150
151    /// Record a retryable-status failure of physical attempt `attempt` (1-based).
152    pub(crate) fn status_failed(&mut self, policy: &RetryPolicy, attempt: u32) -> Option<u32> {
153        self.status = self.status.saturating_add(1);
154        self.may_retry(policy, attempt, self.status, policy.max_status_attempts)
155    }
156}
157
158fn random_unit() -> f64 {
159    let mut b = [0u8; 4];
160    if getrandom::fill(&mut b).is_err() {
161        return 0.0;
162    }
163    u32::from_le_bytes(b) as f64 / u32::MAX as f64
164}
165
166/// Parse a Retry-After header: delta-seconds or an HTTP-date (RFC 9110 section 10.2.3).
167/// A date is turned into a delay from the current clock; a date in the past is zero.
168pub fn parse_retry_after(value: &str) -> Option<Duration> {
169    parse_retry_after_at(value, SystemTime::now())
170}
171
172/// [`parse_retry_after`] against an explicit `now` (for tests and callers with their own clock).
173pub fn parse_retry_after_at(value: &str, now: SystemTime) -> Option<Duration> {
174    let v = value.trim();
175    if let Ok(s) = v.parse::<f64>() {
176        return Some(s)
177            .filter(|s| s.is_finite() && *s >= 0.0)
178            .map(|s| Duration::try_from_secs_f64(s).unwrap_or(Duration::MAX));
179    }
180    let at = parse_http_date(v)?;
181    Some(at.duration_since(now).unwrap_or(Duration::ZERO))
182}
183
184/// Parse a rate-limit reset value (`X-RateLimit-Reset` and similar) into the wait it names,
185/// measured from `now`. Accepted forms: whole Unix epoch seconds (`1760000000`) and an ISO-8601
186/// instant with `T` (or a space) and either `Z` or a `±HH:MM` offset, fractional seconds
187/// allowed (`2026-10-18T10:00:00.5Z`). Fractions finer than nanoseconds round up, so retries
188/// never happen early. A reset already past is `Duration::ZERO`; any other text
189/// is `None`. A bare small integer is read as epoch seconds, so a delta-seconds value is not
190/// accepted here; use [`parse_retry_after_at`] for those.
191pub fn parse_rate_limit_reset_at(value: &str, now: SystemTime) -> Option<Duration> {
192    let v = value.trim();
193    let at = if !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()) {
194        Duration::from_secs(v.parse().ok()?)
195    } else {
196        parse_iso8601_instant(v)?
197    };
198    // Compare as exact durations since the epoch: a `SystemTime` on Windows has 100 ns
199    // resolution, so building the reset instant as one would truncate a sub-100 ns fraction
200    // and report a reset that is still ahead as already reached.
201    let now = now.duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO);
202    Some(at.saturating_sub(now))
203}
204
205/// [`parse_rate_limit_reset_at`] against the current clock.
206pub fn parse_rate_limit_reset(value: &str) -> Option<Duration> {
207    parse_rate_limit_reset_at(value, SystemTime::now())
208}
209
210/// `YYYY-MM-DD[T ]HH:MM:SS[.fraction](Z|±HH:MM)` as the exact time since the Unix epoch,
211/// retaining nanoseconds and rounding finer fractions up to the next nanosecond.
212fn parse_iso8601_instant(v: &str) -> Option<Duration> {
213    let b = v.as_bytes();
214    if b.len() < 20 {
215        return None;
216    }
217    let num = |s: Option<&str>| -> Option<i64> {
218        let s = s?;
219        if !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit()) {
220            s.parse().ok()
221        } else {
222            None
223        }
224    };
225    if b[4] != b'-' || b[7] != b'-' || !matches!(b[10], b'T' | b't' | b' ') {
226        return None;
227    }
228    if b[13] != b':' || b[16] != b':' {
229        return None;
230    }
231    let year = num(v.get(0..4))?;
232    let month = num(v.get(5..7))?;
233    let day = num(v.get(8..10))?;
234    let hour = num(v.get(11..13))?;
235    let minute = num(v.get(14..16))?;
236    let second = num(v.get(17..19))?;
237    if !(1..=12).contains(&month)
238        || !(1..=31).contains(&day)
239        || hour > 23
240        || minute > 59
241        || second > 60
242    {
243        return None;
244    }
245    let mut i = 19;
246    let mut nanos = 0u32;
247    let mut fraction_digits = 0usize;
248    let mut finer_nonzero = false;
249    if b.get(i) == Some(&b'.') {
250        i += 1;
251        let start = i;
252        while let Some(&digit) = b.get(i).filter(|c| c.is_ascii_digit()) {
253            if fraction_digits < 9 {
254                nanos = nanos * 10 + u32::from(digit - b'0');
255            } else if digit != b'0' {
256                finer_nonzero = true;
257            }
258            fraction_digits += 1;
259            i += 1;
260        }
261        if i == start {
262            return None;
263        }
264        for _ in fraction_digits..9 {
265            nanos *= 10;
266        }
267        if finer_nonzero {
268            if nanos == 999_999_999 {
269                nanos = 0;
270                // Carry the sub-nanosecond rounding into whole seconds below.
271                fraction_digits = usize::MAX;
272            } else {
273                nanos += 1;
274            }
275        }
276    }
277    let offset: i64 = match *b.get(i)? {
278        b'Z' | b'z' => {
279            i += 1;
280            0
281        }
282        sign @ (b'+' | b'-') => {
283            if b.get(i + 3) != Some(&b':') {
284                return None;
285            }
286            let oh = num(v.get(i + 1..i + 3))?;
287            let om = num(v.get(i + 4..i + 6))?;
288            i += 6;
289            let secs = oh * 3600 + om * 60;
290            if sign == b'-' {
291                -secs
292            } else {
293                secs
294            }
295        }
296        _ => return None,
297    };
298    if i != b.len() {
299        return None;
300    }
301    let days = days_from_civil(year, month, day);
302    let mut secs = days * 86_400 + hour * 3_600 + minute * 60 + second - offset;
303    if secs < 0 {
304        return None;
305    }
306    if fraction_digits == usize::MAX {
307        secs = secs.checked_add(1)?;
308    }
309    Some(Duration::new(secs as u64, nanos))
310}
311
312fn month_number(m: &str) -> Option<i64> {
313    const MONTHS: [&str; 12] = [
314        "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
315    ];
316    let m = m.get(..3)?.to_ascii_lowercase();
317    MONTHS.iter().position(|x| *x == m).map(|i| i as i64 + 1)
318}
319
320fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
321    let y = if m <= 2 { y - 1 } else { y };
322    let era = y.div_euclid(400);
323    let yoe = y - era * 400;
324    let mp = (m + 9) % 12;
325    let doy = (153 * mp + 2) / 5 + d - 1;
326    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
327    era * 146_097 + doe - 719_468
328}
329
330/// HTTP-date in the three RFC 9110 forms (IMF-fixdate, obsolete RFC 850, asctime), always GMT.
331pub(crate) fn parse_http_date(v: &str) -> Option<SystemTime> {
332    let t: Vec<&str> = v.split_whitespace().collect();
333    let (day, mon, year, time) = match t.len() {
334        6 if t[0].ends_with(',') && t[5].eq_ignore_ascii_case("GMT") => {
335            (t[1], t[2], t[3].parse::<i64>().ok()?, t[4])
336        }
337        4 if t[0].ends_with(',') && t[3].eq_ignore_ascii_case("GMT") => {
338            let mut p = t[1].split('-');
339            let (d, m, y) = (p.next()?, p.next()?, p.next()?);
340            if p.next().is_some() {
341                return None;
342            }
343            let yy = y.parse::<i64>().ok()?;
344            let year = if y.len() == 2 {
345                if yy < 70 {
346                    2000 + yy
347                } else {
348                    1900 + yy
349                }
350            } else {
351                yy
352            };
353            (d, m, year, t[2])
354        }
355        5 => (t[2], t[1], t[4].parse::<i64>().ok()?, t[3]),
356        _ => return None,
357    };
358    let day = day.parse::<i64>().ok()?;
359    let month = month_number(mon)?;
360    let mut hms = time.split(':');
361    let h = hms.next()?.parse::<i64>().ok()?;
362    let mi = hms.next()?.parse::<i64>().ok()?;
363    let s = hms.next()?.parse::<i64>().ok()?;
364    if hms.next().is_some()
365        || !(1..=31).contains(&day)
366        || !(0..24).contains(&h)
367        || !(0..60).contains(&mi)
368        || !(0..=60).contains(&s)
369        || !(1970..=9999).contains(&year)
370    {
371        return None;
372    }
373    let secs = days_from_civil(year, month, day) * 86_400 + h * 3600 + mi * 60 + s;
374    u64::try_from(secs)
375        .ok()
376        .map(|s| UNIX_EPOCH + Duration::from_secs(s))
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn huge_retry_after_saturates_instead_of_panicking() {
385        for v in [
386            "18446744073709551616",
387            "1e300",
388            "99999999999999999999999999999999999999",
389            "1.7976931348623157e308",
390        ] {
391            assert_eq!(parse_retry_after(v), Some(Duration::MAX), "{v}");
392        }
393        assert_eq!(parse_retry_after("1.5"), Some(Duration::from_millis(1500)));
394        assert_eq!(parse_retry_after("-1"), None);
395        assert_eq!(parse_retry_after("NaN"), None);
396        assert_eq!(parse_retry_after("inf"), None);
397    }
398
399    #[test]
400    fn delay_caps_a_saturated_retry_after() {
401        let p = RetryPolicy {
402            max_retry_after: Duration::from_secs(30),
403            jitter: 0.0,
404            ..RetryPolicy::default()
405        };
406        assert_eq!(p.delay(1, Some(Duration::MAX)), Duration::from_secs(30));
407    }
408
409    #[test]
410    fn backoff_with_absurd_settings_does_not_panic() {
411        let p = RetryPolicy {
412            initial_backoff: Duration::MAX,
413            max_backoff: Duration::MAX,
414            multiplier: f64::MAX,
415            jitter: 0.0,
416            ..RetryPolicy::default()
417        };
418        let _ = p.backoff(40);
419    }
420
421    #[test]
422    fn retry_after_http_date_forms() {
423        let at = UNIX_EPOCH + Duration::from_secs(784_111_777); // Sun, 06 Nov 1994 08:49:37 GMT
424        let now = at - Duration::from_secs(90);
425        for v in [
426            "Sun, 06 Nov 1994 08:49:37 GMT",
427            "Sunday, 06-Nov-94 08:49:37 GMT",
428            "Sun Nov  6 08:49:37 1994",
429        ] {
430            assert_eq!(
431                parse_retry_after_at(v, now),
432                Some(Duration::from_secs(90)),
433                "{v}"
434            );
435        }
436        // A date in the past is a zero delay; junk is None.
437        assert_eq!(
438            parse_retry_after_at("Sun, 06 Nov 1994 08:49:37 GMT", at + Duration::from_secs(5)),
439            Some(Duration::ZERO)
440        );
441        assert_eq!(parse_retry_after_at("soon", now), None);
442        assert_eq!(
443            parse_retry_after_at("Sun, 32 Nov 1994 08:49:37 GMT", now),
444            None
445        );
446    }
447
448    #[test]
449    fn retry_after_beyond_the_cap_is_kept_whole_and_defers_on_request() {
450        assert_eq!(parse_retry_after("3600"), Some(Duration::from_secs(3600)));
451        let mut p = RetryPolicy::default();
452        assert!(!p.defers(Some(Duration::from_secs(3600))));
453        p.defer_over_cap = true;
454        assert!(p.defers(Some(Duration::from_secs(3600))));
455        assert!(!p.defers(Some(Duration::from_secs(10))));
456        assert!(!p.defers(None));
457        p.honor_retry_after = false;
458        assert!(!p.defers(Some(Duration::from_secs(3600))));
459    }
460
461    #[test]
462    fn separate_budgets_do_not_steal_from_each_other_and_stay_finite() {
463        let policy = RetryPolicy {
464            max_attempts: 100,
465            max_transport_attempts: Some(2),
466            max_status_attempts: Some(3),
467            ..RetryPolicy::default()
468        };
469        let mut b = AttemptBudgets::new();
470        // Two transport failures exhaust only the transport budget.
471        assert_eq!(b.transport_failed(&policy, 1), Some(1));
472        assert_eq!(b.transport_failed(&policy, 2), None);
473        // The status budget is untouched (and its backoff starts at 1, not 3).
474        assert_eq!(b.status_failed(&policy, 3), Some(1));
475        assert_eq!(b.status_failed(&policy, 4), Some(2));
476        assert_eq!(b.status_failed(&policy, 5), None);
477        // Alternation never resets a counter.
478        assert_eq!(b.transport_failed(&policy, 6), None);
479        // The total physical cap binds even with unbounded-looking kind budgets.
480        let capped = RetryPolicy {
481            max_attempts: 4,
482            max_transport_attempts: Some(10),
483            max_status_attempts: Some(10),
484            ..RetryPolicy::default()
485        };
486        let mut b = AttemptBudgets::new();
487        assert!(b.transport_failed(&capped, 1).is_some());
488        assert!(b.status_failed(&capped, 2).is_some());
489        assert!(b.transport_failed(&capped, 3).is_some());
490        assert_eq!(b.status_failed(&capped, 4), None);
491        // Legacy policy: one shared counter, backoff index is the attempt number.
492        let legacy = RetryPolicy {
493            max_attempts: 3,
494            ..RetryPolicy::default()
495        };
496        assert!(!legacy.separate_budgets());
497        let mut b = AttemptBudgets::new();
498        assert_eq!(b.transport_failed(&legacy, 1), Some(1));
499        assert_eq!(b.status_failed(&legacy, 2), Some(2));
500        assert_eq!(b.transport_failed(&legacy, 3), None);
501    }
502
503    #[test]
504    fn rate_limit_reset_epoch_and_iso_forms() {
505        // 2026-10-18T10:00:00Z is 1792317600 seconds after the epoch.
506        let now = UNIX_EPOCH + Duration::from_secs(1_792_317_600 - 90);
507        let want = Some(Duration::from_secs(90));
508        assert_eq!(parse_rate_limit_reset_at("1792317600", now), want);
509        assert_eq!(parse_rate_limit_reset_at("2026-10-18T10:00:00Z", now), want);
510        assert_eq!(
511            parse_rate_limit_reset_at("2026-10-18T12:00:00+02:00", now),
512            want
513        );
514        assert_eq!(
515            parse_rate_limit_reset_at("2026-10-18 10:00:00.250Z", now),
516            Some(Duration::from_millis(90_250))
517        );
518        let at_whole_second = UNIX_EPOCH + Duration::from_secs(1_792_317_600);
519        assert_eq!(
520            parse_rate_limit_reset_at("2026-10-18T10:00:00.5Z", at_whole_second),
521            Some(Duration::from_millis(500))
522        );
523        assert_eq!(
524            parse_rate_limit_reset_at("2026-10-18T10:00:00.0000000001Z", at_whole_second),
525            Some(Duration::from_nanos(1))
526        );
527        // `now` itself is a SystemTime, so its precision is the host's: 1 ns on Unix, 100 ns on
528        // Windows. A reset at or before `now` is zero at that precision.
529        let tick = if cfg!(windows) { 100 } else { 1 };
530        assert_eq!(
531            parse_rate_limit_reset_at(
532                "2026-10-18T10:00:00.0000000001Z",
533                at_whole_second + Duration::from_nanos(tick)
534            ),
535            Some(Duration::ZERO)
536        );
537        assert_eq!(
538            parse_rate_limit_reset_at("2026-10-18T10:00:00-02:00", now),
539            Some(Duration::from_secs(90 + 7200))
540        );
541        assert_eq!(parse_rate_limit_reset_at("1000", now), Some(Duration::ZERO));
542        assert_eq!(parse_rate_limit_reset_at("2026-13-18T10:00:00Z", now), None);
543        assert_eq!(parse_rate_limit_reset_at("2026-10-18T10:00:00", now), None);
544        assert_eq!(parse_rate_limit_reset_at("soon", now), None);
545        assert_eq!(parse_rate_limit_reset_at("", now), None);
546    }
547}