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:00Z`). A reset already past is `Duration::ZERO`; any other text
188/// is `None`. A bare small integer is read as epoch seconds, so a delta-seconds value is not
189/// accepted here; use [`parse_retry_after_at`] for those.
190pub fn parse_rate_limit_reset_at(value: &str, now: SystemTime) -> Option<Duration> {
191    let v = value.trim();
192    let at = if !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()) {
193        let secs: u64 = v.parse().ok()?;
194        UNIX_EPOCH.checked_add(Duration::from_secs(secs))?
195    } else {
196        parse_iso8601_instant(v)?
197    };
198    Some(at.duration_since(now).unwrap_or(Duration::ZERO))
199}
200
201/// [`parse_rate_limit_reset_at`] against the current clock.
202pub fn parse_rate_limit_reset(value: &str) -> Option<Duration> {
203    parse_rate_limit_reset_at(value, SystemTime::now())
204}
205
206/// `YYYY-MM-DD[T ]HH:MM:SS[.fraction](Z|±HH:MM)`, fraction ignored.
207fn parse_iso8601_instant(v: &str) -> Option<SystemTime> {
208    let b = v.as_bytes();
209    if b.len() < 20 {
210        return None;
211    }
212    let num = |s: Option<&str>| -> Option<i64> {
213        let s = s?;
214        if !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit()) {
215            s.parse().ok()
216        } else {
217            None
218        }
219    };
220    if b[4] != b'-' || b[7] != b'-' || !matches!(b[10], b'T' | b't' | b' ') {
221        return None;
222    }
223    if b[13] != b':' || b[16] != b':' {
224        return None;
225    }
226    let year = num(v.get(0..4))?;
227    let month = num(v.get(5..7))?;
228    let day = num(v.get(8..10))?;
229    let hour = num(v.get(11..13))?;
230    let minute = num(v.get(14..16))?;
231    let second = num(v.get(17..19))?;
232    if !(1..=12).contains(&month)
233        || !(1..=31).contains(&day)
234        || hour > 23
235        || minute > 59
236        || second > 60
237    {
238        return None;
239    }
240    let mut i = 19;
241    if b.get(i) == Some(&b'.') {
242        i += 1;
243        let start = i;
244        while b.get(i).is_some_and(|c| c.is_ascii_digit()) {
245            i += 1;
246        }
247        if i == start {
248            return None;
249        }
250    }
251    let offset: i64 = match *b.get(i)? {
252        b'Z' | b'z' => {
253            i += 1;
254            0
255        }
256        sign @ (b'+' | b'-') => {
257            if b.get(i + 3) != Some(&b':') {
258                return None;
259            }
260            let oh = num(v.get(i + 1..i + 3))?;
261            let om = num(v.get(i + 4..i + 6))?;
262            i += 6;
263            let secs = oh * 3600 + om * 60;
264            if sign == b'-' {
265                -secs
266            } else {
267                secs
268            }
269        }
270        _ => return None,
271    };
272    if i != b.len() {
273        return None;
274    }
275    let days = days_from_civil(year, month, day);
276    let secs = days * 86_400 + hour * 3_600 + minute * 60 + second - offset;
277    if secs < 0 {
278        return None;
279    }
280    Some(UNIX_EPOCH + Duration::from_secs(secs as u64))
281}
282
283fn month_number(m: &str) -> Option<i64> {
284    const MONTHS: [&str; 12] = [
285        "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
286    ];
287    let m = m.get(..3)?.to_ascii_lowercase();
288    MONTHS.iter().position(|x| *x == m).map(|i| i as i64 + 1)
289}
290
291fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
292    let y = if m <= 2 { y - 1 } else { y };
293    let era = y.div_euclid(400);
294    let yoe = y - era * 400;
295    let mp = (m + 9) % 12;
296    let doy = (153 * mp + 2) / 5 + d - 1;
297    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
298    era * 146_097 + doe - 719_468
299}
300
301/// HTTP-date in the three RFC 9110 forms (IMF-fixdate, obsolete RFC 850, asctime), always GMT.
302pub(crate) fn parse_http_date(v: &str) -> Option<SystemTime> {
303    let t: Vec<&str> = v.split_whitespace().collect();
304    let (day, mon, year, time) = match t.len() {
305        6 if t[0].ends_with(',') && t[5].eq_ignore_ascii_case("GMT") => {
306            (t[1], t[2], t[3].parse::<i64>().ok()?, t[4])
307        }
308        4 if t[0].ends_with(',') && t[3].eq_ignore_ascii_case("GMT") => {
309            let mut p = t[1].split('-');
310            let (d, m, y) = (p.next()?, p.next()?, p.next()?);
311            if p.next().is_some() {
312                return None;
313            }
314            let yy = y.parse::<i64>().ok()?;
315            let year = if y.len() == 2 {
316                if yy < 70 {
317                    2000 + yy
318                } else {
319                    1900 + yy
320                }
321            } else {
322                yy
323            };
324            (d, m, year, t[2])
325        }
326        5 => (t[2], t[1], t[4].parse::<i64>().ok()?, t[3]),
327        _ => return None,
328    };
329    let day = day.parse::<i64>().ok()?;
330    let month = month_number(mon)?;
331    let mut hms = time.split(':');
332    let h = hms.next()?.parse::<i64>().ok()?;
333    let mi = hms.next()?.parse::<i64>().ok()?;
334    let s = hms.next()?.parse::<i64>().ok()?;
335    if hms.next().is_some()
336        || !(1..=31).contains(&day)
337        || !(0..24).contains(&h)
338        || !(0..60).contains(&mi)
339        || !(0..=60).contains(&s)
340        || !(1970..=9999).contains(&year)
341    {
342        return None;
343    }
344    let secs = days_from_civil(year, month, day) * 86_400 + h * 3600 + mi * 60 + s;
345    u64::try_from(secs)
346        .ok()
347        .map(|s| UNIX_EPOCH + Duration::from_secs(s))
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn huge_retry_after_saturates_instead_of_panicking() {
356        for v in [
357            "18446744073709551616",
358            "1e300",
359            "99999999999999999999999999999999999999",
360            "1.7976931348623157e308",
361        ] {
362            assert_eq!(parse_retry_after(v), Some(Duration::MAX), "{v}");
363        }
364        assert_eq!(parse_retry_after("1.5"), Some(Duration::from_millis(1500)));
365        assert_eq!(parse_retry_after("-1"), None);
366        assert_eq!(parse_retry_after("NaN"), None);
367        assert_eq!(parse_retry_after("inf"), None);
368    }
369
370    #[test]
371    fn delay_caps_a_saturated_retry_after() {
372        let p = RetryPolicy {
373            max_retry_after: Duration::from_secs(30),
374            jitter: 0.0,
375            ..RetryPolicy::default()
376        };
377        assert_eq!(p.delay(1, Some(Duration::MAX)), Duration::from_secs(30));
378    }
379
380    #[test]
381    fn backoff_with_absurd_settings_does_not_panic() {
382        let p = RetryPolicy {
383            initial_backoff: Duration::MAX,
384            max_backoff: Duration::MAX,
385            multiplier: f64::MAX,
386            jitter: 0.0,
387            ..RetryPolicy::default()
388        };
389        let _ = p.backoff(40);
390    }
391
392    #[test]
393    fn retry_after_http_date_forms() {
394        let at = UNIX_EPOCH + Duration::from_secs(784_111_777); // Sun, 06 Nov 1994 08:49:37 GMT
395        let now = at - Duration::from_secs(90);
396        for v in [
397            "Sun, 06 Nov 1994 08:49:37 GMT",
398            "Sunday, 06-Nov-94 08:49:37 GMT",
399            "Sun Nov  6 08:49:37 1994",
400        ] {
401            assert_eq!(
402                parse_retry_after_at(v, now),
403                Some(Duration::from_secs(90)),
404                "{v}"
405            );
406        }
407        // A date in the past is a zero delay; junk is None.
408        assert_eq!(
409            parse_retry_after_at("Sun, 06 Nov 1994 08:49:37 GMT", at + Duration::from_secs(5)),
410            Some(Duration::ZERO)
411        );
412        assert_eq!(parse_retry_after_at("soon", now), None);
413        assert_eq!(
414            parse_retry_after_at("Sun, 32 Nov 1994 08:49:37 GMT", now),
415            None
416        );
417    }
418
419    #[test]
420    fn retry_after_beyond_the_cap_is_kept_whole_and_defers_on_request() {
421        assert_eq!(parse_retry_after("3600"), Some(Duration::from_secs(3600)));
422        let mut p = RetryPolicy::default();
423        assert!(!p.defers(Some(Duration::from_secs(3600))));
424        p.defer_over_cap = true;
425        assert!(p.defers(Some(Duration::from_secs(3600))));
426        assert!(!p.defers(Some(Duration::from_secs(10))));
427        assert!(!p.defers(None));
428        p.honor_retry_after = false;
429        assert!(!p.defers(Some(Duration::from_secs(3600))));
430    }
431
432    #[test]
433    fn separate_budgets_do_not_steal_from_each_other_and_stay_finite() {
434        let policy = RetryPolicy {
435            max_attempts: 100,
436            max_transport_attempts: Some(2),
437            max_status_attempts: Some(3),
438            ..RetryPolicy::default()
439        };
440        let mut b = AttemptBudgets::new();
441        // Two transport failures exhaust only the transport budget.
442        assert_eq!(b.transport_failed(&policy, 1), Some(1));
443        assert_eq!(b.transport_failed(&policy, 2), None);
444        // The status budget is untouched (and its backoff starts at 1, not 3).
445        assert_eq!(b.status_failed(&policy, 3), Some(1));
446        assert_eq!(b.status_failed(&policy, 4), Some(2));
447        assert_eq!(b.status_failed(&policy, 5), None);
448        // Alternation never resets a counter.
449        assert_eq!(b.transport_failed(&policy, 6), None);
450        // The total physical cap binds even with unbounded-looking kind budgets.
451        let capped = RetryPolicy {
452            max_attempts: 4,
453            max_transport_attempts: Some(10),
454            max_status_attempts: Some(10),
455            ..RetryPolicy::default()
456        };
457        let mut b = AttemptBudgets::new();
458        assert!(b.transport_failed(&capped, 1).is_some());
459        assert!(b.status_failed(&capped, 2).is_some());
460        assert!(b.transport_failed(&capped, 3).is_some());
461        assert_eq!(b.status_failed(&capped, 4), None);
462        // Legacy policy: one shared counter, backoff index is the attempt number.
463        let legacy = RetryPolicy {
464            max_attempts: 3,
465            ..RetryPolicy::default()
466        };
467        assert!(!legacy.separate_budgets());
468        let mut b = AttemptBudgets::new();
469        assert_eq!(b.transport_failed(&legacy, 1), Some(1));
470        assert_eq!(b.status_failed(&legacy, 2), Some(2));
471        assert_eq!(b.transport_failed(&legacy, 3), None);
472    }
473
474    #[test]
475    fn rate_limit_reset_epoch_and_iso_forms() {
476        // 2026-10-18T10:00:00Z is 1792317600 seconds after the epoch.
477        let now = UNIX_EPOCH + Duration::from_secs(1_792_317_600 - 90);
478        let want = Some(Duration::from_secs(90));
479        assert_eq!(parse_rate_limit_reset_at("1792317600", now), want);
480        assert_eq!(parse_rate_limit_reset_at("2026-10-18T10:00:00Z", now), want);
481        assert_eq!(
482            parse_rate_limit_reset_at("2026-10-18T12:00:00+02:00", now),
483            want
484        );
485        assert_eq!(
486            parse_rate_limit_reset_at("2026-10-18 10:00:00.250Z", now),
487            Some(Duration::from_secs(90))
488        );
489        assert_eq!(
490            parse_rate_limit_reset_at("2026-10-18T10:00:00-02:00", now),
491            Some(Duration::from_secs(90 + 7200))
492        );
493        assert_eq!(parse_rate_limit_reset_at("1000", now), Some(Duration::ZERO));
494        assert_eq!(parse_rate_limit_reset_at("2026-13-18T10:00:00Z", now), None);
495        assert_eq!(parse_rate_limit_reset_at("2026-10-18T10:00:00", now), None);
496        assert_eq!(parse_rate_limit_reset_at("soon", now), None);
497        assert_eq!(parse_rate_limit_reset_at("", now), None);
498    }
499}