Skip to main content

baukit_http/
retry.rs

1//! Classification of upstream HTTP responses and transport failures.
2//!
3//! Outbound clients all need the same decision: was this failure worth
4//! retrying, and if so, after how long? [`classify_http_status`] answers that
5//! from the status code and response headers alone, so it stays a pure function
6//! that is easy to test and free of any particular HTTP client.
7
8use std::time::Duration;
9
10use axum::http::{HeaderMap, HeaderName, StatusCode, header::RETRY_AFTER};
11use time::{OffsetDateTime, format_description::well_known::Rfc2822};
12
13/// How a caller should react to a failed upstream request.
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum RetryClass {
16    /// The upstream asked for a specific wait before the next attempt.
17    ///
18    /// A rate limit or server failure came with a usable retry delay.
19    RetryAfter(Duration),
20    /// The upstream rate limited the request without saying for how long.
21    ///
22    /// Back off with the client's own schedule.
23    RateLimited,
24    /// The upstream is failing or unreachable and the request may be retried.
25    Unavailable,
26    /// The request timed out before a response arrived.
27    Timeout,
28    /// The credential was rejected; re-authorize instead of retrying.
29    Revoked,
30    /// The request will fail the same way again; do not retry.
31    Permanent,
32}
33
34/// Options for reading retry delays from response headers.
35///
36/// Vendor headers are read in order before the standard `Retry-After` header.
37/// By default, each header value is parsed whole. Use
38/// [`RetryHeaderOptions::with_first_field`] for APIs such as Polar that return
39/// comma-separated quota windows and define the first field as the active one.
40///
41/// Parsed delays are uncapped by default, so the classifier reports what the
42/// upstream sent. A client that schedules its own retries should set
43/// [`RetryHeaderOptions::with_max_retry_after`].
44/// Use [`RetryHeaderOptions::with_forbidden_rate_limit`] for APIs such as
45/// GitHub that also report rate limits with `403`.
46#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
47pub struct RetryHeaderOptions<'a> {
48    extra_retry_after_headers: &'a [&'a str],
49    first_field: bool,
50    max_retry_after: Option<Duration>,
51    forbidden_rate_limit: bool,
52}
53
54impl<'a> RetryHeaderOptions<'a> {
55    /// Creates options that consult the named vendor headers in order.
56    pub const fn new(extra_retry_after_headers: &'a [&'a str]) -> Self {
57        Self {
58            extra_retry_after_headers,
59            first_field: false,
60            max_retry_after: None,
61            forbidden_rate_limit: false,
62        }
63    }
64
65    /// Parses only the first comma-separated field of vendor header values.
66    ///
67    /// This does not split `Retry-After`, whose HTTP-date form contains a
68    /// comma.
69    #[must_use]
70    pub const fn with_first_field(mut self) -> Self {
71        self.first_field = true;
72        self
73    }
74
75    /// Clamps parsed delays to `max`.
76    ///
77    /// An upstream that sends `Retry-After: 86400` then yields `max` instead of
78    /// stalling a worker for a day.
79    #[must_use]
80    pub const fn with_max_retry_after(mut self, max: Duration) -> Self {
81        self.max_retry_after = Some(max);
82        self
83    }
84
85    /// Recognizes rate-limit headers on `403` responses.
86    ///
87    /// A usable retry header gives [`RetryClass::RetryAfter`]. Otherwise,
88    /// `x-ratelimit-remaining: 0` gives [`RetryClass::RetryAfter`] when
89    /// `x-ratelimit-reset` is a valid Unix epoch in seconds, or
90    /// [`RetryClass::RateLimited`] without a usable reset. Other `403`
91    /// responses and all `401` responses stay [`RetryClass::Revoked`].
92    #[must_use]
93    pub const fn with_forbidden_rate_limit(mut self) -> Self {
94        self.forbidden_rate_limit = true;
95        self
96    }
97
98    fn cap(self, delay: Duration) -> Duration {
99        match self.max_retry_after {
100            Some(max) => delay.min(max),
101            None => delay,
102        }
103    }
104}
105
106impl RetryClass {
107    /// Returns whether retrying the same request can succeed.
108    ///
109    /// [`RetryClass::Revoked`] and [`RetryClass::Permanent`] return `false`:
110    /// both need an operator or a re-authorization, not another attempt.
111    pub const fn is_retryable(self) -> bool {
112        matches!(
113            self,
114            Self::RetryAfter(_) | Self::RateLimited | Self::Unavailable | Self::Timeout
115        )
116    }
117
118    /// Returns the wait the upstream asked for, if it named one.
119    pub const fn retry_after(self) -> Option<Duration> {
120        match self {
121            Self::RetryAfter(delay) => Some(delay),
122            _ => None,
123        }
124    }
125}
126
127/// Classifies an upstream HTTP response by status and headers.
128///
129/// The mapping is:
130///
131/// - `401` and `403` are [`RetryClass::Revoked`]; the credential needs renewing.
132/// - `408` is [`RetryClass::Timeout`].
133/// - `425` is [`RetryClass::Unavailable`]. RFC 8470 section 5.2 lets a client
134///   retry once the request is no longer sent as early data.
135/// - `429` is [`RetryClass::RetryAfter`] when a retry-after header parses, and
136///   [`RetryClass::RateLimited`] otherwise. The delay is uncapped; see
137///   [`RetryHeaderOptions::with_max_retry_after`].
138/// - Any `5xx`, including `504`, is [`RetryClass::RetryAfter`] when a
139///   retry-after header parses. Without a usable delay, `504` is
140///   [`RetryClass::Timeout`] and other `5xx` are [`RetryClass::Unavailable`].
141/// - Everything else, success codes included, is [`RetryClass::Permanent`];
142///   callers only reach this function for responses they already treat as
143///   failures.
144///
145/// `extra_retry_after_headers` names vendor headers to consult *before* the
146/// standard `Retry-After`, in the order given. Fitbit, for instance, sends the
147/// remaining window in `fitbit-rate-limit-reset`, so that integration passes
148/// `&["fitbit-rate-limit-reset"]` while every other one passes `&[]`. Names that
149/// are not valid header names are skipped.
150///
151/// # Example
152///
153/// ```rust
154/// use std::time::Duration;
155///
156/// use baukit_http::{RetryClass, classify_http_status};
157/// use axum::http::{HeaderMap, HeaderValue, StatusCode, header::RETRY_AFTER};
158///
159/// let mut headers = HeaderMap::new();
160/// headers.insert(RETRY_AFTER, HeaderValue::from_static("30"));
161/// assert_eq!(
162///     classify_http_status(StatusCode::TOO_MANY_REQUESTS, &headers, &[]),
163///     RetryClass::RetryAfter(Duration::from_secs(30))
164/// );
165/// assert_eq!(
166///     classify_http_status(StatusCode::FORBIDDEN, &HeaderMap::new(), &[]),
167///     RetryClass::Revoked
168/// );
169/// ```
170pub fn classify_http_status(
171    status: StatusCode,
172    headers: &HeaderMap,
173    extra_retry_after_headers: &[&str],
174) -> RetryClass {
175    classify_http_status_with_options(
176        status,
177        headers,
178        RetryHeaderOptions::new(extra_retry_after_headers),
179    )
180}
181
182/// Classifies an upstream HTTP response with configurable retry header parsing.
183///
184/// See [`classify_http_status`] for the status mapping. The options control
185/// which vendor retry headers are checked, whether their first
186/// comma-separated field is used, and the largest delay reported.
187/// [`RetryHeaderOptions::with_forbidden_rate_limit`] also recognizes rate
188/// limits on `403` responses. `401` always stays [`RetryClass::Revoked`].
189/// Usable retry headers on any `5xx` give [`RetryClass::RetryAfter`], capped
190/// by [`RetryHeaderOptions::with_max_retry_after`].
191pub fn classify_http_status_with_options(
192    status: StatusCode,
193    headers: &HeaderMap,
194    options: RetryHeaderOptions<'_>,
195) -> RetryClass {
196    classify_http_status_with_options_at(status, headers, options, OffsetDateTime::now_utc())
197}
198
199/// Classifies an upstream HTTP response using `options` at `now`.
200///
201/// See [`classify_http_status_with_options`] for the status mapping.
202/// `now` is the reference instant for HTTP-date retry headers and the
203/// `x-ratelimit-reset` epoch on opted-in `403` responses. Past deadlines
204/// yield [`Duration::ZERO`], and delays are clamped to the configured cap.
205/// Any `5xx` with a usable retry header gives [`RetryClass::RetryAfter`].
206pub fn classify_http_status_with_options_at(
207    status: StatusCode,
208    headers: &HeaderMap,
209    options: RetryHeaderOptions<'_>,
210    now: OffsetDateTime,
211) -> RetryClass {
212    match status {
213        StatusCode::FORBIDDEN if options.forbidden_rate_limit => {
214            classify_forbidden_rate_limit(headers, options, now)
215        }
216        StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => RetryClass::Revoked,
217        StatusCode::REQUEST_TIMEOUT => RetryClass::Timeout,
218        StatusCode::TOO_EARLY => RetryClass::Unavailable,
219        StatusCode::TOO_MANY_REQUESTS => {
220            retry_after_from_headers_with_options_at(headers, options, now)
221                .map_or(RetryClass::RateLimited, RetryClass::RetryAfter)
222        }
223        _ if status.is_server_error() => {
224            let fallback = if status == StatusCode::GATEWAY_TIMEOUT {
225                RetryClass::Timeout
226            } else {
227                RetryClass::Unavailable
228            };
229            retry_after_from_headers_with_options_at(headers, options, now)
230                .map_or(fallback, RetryClass::RetryAfter)
231        }
232        _ => RetryClass::Permanent,
233    }
234}
235
236fn classify_forbidden_rate_limit(
237    headers: &HeaderMap,
238    options: RetryHeaderOptions<'_>,
239    now: OffsetDateTime,
240) -> RetryClass {
241    if let Some(delay) = retry_after_from_headers_with_options_at(headers, options, now) {
242        return RetryClass::RetryAfter(delay);
243    }
244    let remaining = headers
245        .get("x-ratelimit-remaining")
246        .and_then(|value| value.to_str().ok());
247    if remaining.map(str::trim) != Some("0") {
248        return RetryClass::Revoked;
249    }
250    let reset = headers
251        .get("x-ratelimit-reset")
252        .and_then(|value| value.to_str().ok())
253        .and_then(|value| value.trim().parse::<i64>().ok())
254        .and_then(|epoch| OffsetDateTime::from_unix_timestamp(epoch).ok());
255    reset.map_or(RetryClass::RateLimited, |deadline| {
256        let delay = (deadline - now).try_into().unwrap_or(Duration::ZERO);
257        RetryClass::RetryAfter(options.cap(delay))
258    })
259}
260
261/// Reads the retry delay from the vendor headers first, then `Retry-After`.
262///
263/// Both `Retry-After` forms are accepted: delay seconds (`Retry-After: 120`)
264/// and an HTTP-date (`Retry-After: Wed, 21 Oct 2026 07:28:00 GMT`). A date in
265/// the past yields [`Duration::ZERO`], meaning retry now. Values that parse as
266/// neither are ignored, and the next candidate header is tried.
267///
268/// `now` is the reference instant for HTTP-date values; pass
269/// [`OffsetDateTime::now_utc`].
270pub fn retry_after_from_headers_at(
271    headers: &HeaderMap,
272    extra_retry_after_headers: &[&str],
273    now: OffsetDateTime,
274) -> Option<Duration> {
275    retry_after_from_headers_with_options_at(
276        headers,
277        RetryHeaderOptions::new(extra_retry_after_headers),
278        now,
279    )
280}
281
282/// Reads a retry delay using configurable vendor header parsing at `now`.
283///
284/// Vendor headers are checked before `Retry-After`. Invalid names and values
285/// are skipped. The standard header always keeps its whole value so HTTP dates
286/// continue to parse when first-field handling is enabled for vendor headers.
287/// A delay above [`RetryHeaderOptions::with_max_retry_after`] is clamped.
288pub fn retry_after_from_headers_with_options_at(
289    headers: &HeaderMap,
290    options: RetryHeaderOptions<'_>,
291    now: OffsetDateTime,
292) -> Option<Duration> {
293    let vendor_delay = options
294        .extra_retry_after_headers
295        .iter()
296        .filter_map(|name| HeaderName::try_from(*name).ok())
297        .filter_map(|name| headers.get(&name).and_then(|value| value.to_str().ok()))
298        .find_map(|value| {
299            let value = if options.first_field {
300                value.split(',').next().unwrap_or(value)
301            } else {
302                value
303            };
304            parse_retry_after(value, now)
305        });
306
307    vendor_delay
308        .or_else(|| {
309            headers
310                .get(RETRY_AFTER)
311                .and_then(|value| value.to_str().ok())
312                .and_then(|value| parse_retry_after(value, now))
313        })
314        .map(|delay| options.cap(delay))
315}
316
317/// Reads the retry delay relative to the current time.
318///
319/// See [`retry_after_from_headers_at`] for the accepted formats.
320pub fn retry_after_from_headers(
321    headers: &HeaderMap,
322    extra_retry_after_headers: &[&str],
323) -> Option<Duration> {
324    retry_after_from_headers_at(
325        headers,
326        extra_retry_after_headers,
327        OffsetDateTime::now_utc(),
328    )
329}
330
331/// Reads a retry delay relative to the current time using `options`.
332///
333/// See [`retry_after_from_headers_with_options_at`] for the accepted formats.
334pub fn retry_after_from_headers_with_options(
335    headers: &HeaderMap,
336    options: RetryHeaderOptions<'_>,
337) -> Option<Duration> {
338    retry_after_from_headers_with_options_at(headers, options, OffsetDateTime::now_utc())
339}
340
341/// Classifies a transport failure that never produced a response.
342///
343/// HTTP clients report timeouts differently, so the caller decides: pass
344/// `is_timeout` from the client's own predicate, for example
345/// `reqwest::Error::is_timeout`. Everything else is treated as a transient
346/// connection problem.
347pub const fn classify_transport_error(is_timeout: bool) -> RetryClass {
348    if is_timeout {
349        RetryClass::Timeout
350    } else {
351        RetryClass::Unavailable
352    }
353}
354
355fn parse_retry_after(value: &str, now: OffsetDateTime) -> Option<Duration> {
356    let value = value.trim();
357    if let Ok(seconds) = value.parse::<u64>() {
358        return Some(Duration::from_secs(seconds));
359    }
360    let deadline = OffsetDateTime::parse(value, &Rfc2822).ok()?;
361    Some((deadline - now).try_into().unwrap_or(Duration::ZERO))
362}
363
364#[cfg(test)]
365mod tests {
366    use axum::http::HeaderValue;
367
368    use super::*;
369
370    const FITBIT_RESET: &str = "fitbit-rate-limit-reset";
371
372    fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
373        let mut map = HeaderMap::new();
374        for (name, value) in pairs {
375            map.insert(
376                HeaderName::try_from(*name).expect("header name should be valid"),
377                HeaderValue::from_str(value).expect("header value should be valid"),
378            );
379        }
380        map
381    }
382
383    fn at(text: &str) -> OffsetDateTime {
384        OffsetDateTime::parse(text, &Rfc2822).expect("reference date should parse")
385    }
386
387    #[test]
388    fn credentials_rejected_by_upstream_are_revoked() {
389        for status in [StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN] {
390            assert_eq!(
391                classify_http_status(status, &HeaderMap::new(), &[]),
392                RetryClass::Revoked
393            );
394        }
395    }
396
397    #[test]
398    fn forbidden_rate_limit_reads_retry_after_seconds() {
399        let options = RetryHeaderOptions::default().with_forbidden_rate_limit();
400        for seconds in [0, 120, 86_400] {
401            assert_eq!(
402                classify_http_status_with_options(
403                    StatusCode::FORBIDDEN,
404                    &headers(&[("retry-after", &seconds.to_string())]),
405                    options,
406                ),
407                RetryClass::RetryAfter(Duration::from_secs(seconds))
408            );
409        }
410    }
411
412    #[test]
413    fn forbidden_rate_limit_reads_retry_after_http_dates() {
414        let now = at("Wed, 21 Oct 2026 07:28:00 GMT");
415        let options = RetryHeaderOptions::default().with_forbidden_rate_limit();
416        let cases = [
417            ("Wed, 21 Oct 2026 07:30:00 GMT", Duration::from_secs(120)),
418            ("Wed, 21 Oct 2026 07:00:00 GMT", Duration::ZERO),
419        ];
420        for (date, delay) in cases {
421            assert_eq!(
422                classify_http_status_with_options_at(
423                    StatusCode::FORBIDDEN,
424                    &headers(&[("retry-after", date)]),
425                    options,
426                    now,
427                ),
428                RetryClass::RetryAfter(delay)
429            );
430        }
431    }
432
433    #[test]
434    fn forbidden_rate_limit_reads_reset_epochs_and_saturates_past_resets() {
435        let now = OffsetDateTime::from_unix_timestamp(1_000).expect("reference epoch is valid");
436        let options = RetryHeaderOptions::default().with_forbidden_rate_limit();
437        for (reset, delay) in [
438            ("1120", Duration::from_secs(120)),
439            ("1000", Duration::ZERO),
440            ("880", Duration::ZERO),
441            ("-1", Duration::ZERO),
442        ] {
443            assert_eq!(
444                classify_http_status_with_options_at(
445                    StatusCode::FORBIDDEN,
446                    &headers(&[("x-ratelimit-remaining", "0"), ("x-ratelimit-reset", reset)]),
447                    options,
448                    now,
449                ),
450                RetryClass::RetryAfter(delay),
451                "reset: {reset}"
452            );
453        }
454    }
455
456    #[test]
457    fn forbidden_rate_limit_without_a_usable_reset_has_no_delay() {
458        let now = OffsetDateTime::from_unix_timestamp(1_000).expect("reference epoch is valid");
459        let options = RetryHeaderOptions::default().with_forbidden_rate_limit();
460        for reset in [
461            None,
462            Some("later"),
463            Some("Wed, 21 Oct 2026 07:30:00 GMT"),
464            Some("1120.5"),
465            Some("9223372036854775807"),
466            Some("18446744073709551616"),
467        ] {
468            let mut map = headers(&[("x-ratelimit-remaining", "0"), ("retry-after", "soon")]);
469            if let Some(reset) = reset {
470                map.insert(
471                    "x-ratelimit-reset",
472                    HeaderValue::from_str(reset).expect("reset should be a valid header value"),
473                );
474            }
475            assert_eq!(
476                classify_http_status_with_options_at(StatusCode::FORBIDDEN, &map, options, now),
477                RetryClass::RateLimited,
478                "reset: {reset:?}"
479            );
480        }
481    }
482
483    #[test]
484    fn forbidden_without_rate_limit_evidence_stays_revoked() {
485        let now = OffsetDateTime::from_unix_timestamp(1_000).expect("reference epoch is valid");
486        let options = RetryHeaderOptions::default().with_forbidden_rate_limit();
487        let cases = [
488            HeaderMap::new(),
489            headers(&[("retry-after", "soon")]),
490            headers(&[("x-ratelimit-reset", "1120")]),
491            headers(&[
492                ("x-ratelimit-remaining", "1"),
493                ("x-ratelimit-reset", "1120"),
494            ]),
495            headers(&[
496                ("x-ratelimit-remaining", "invalid"),
497                ("x-ratelimit-reset", "1120"),
498            ]),
499        ];
500        for map in cases {
501            assert_eq!(
502                classify_http_status_with_options_at(StatusCode::FORBIDDEN, &map, options, now),
503                RetryClass::Revoked,
504                "headers: {map:?}"
505            );
506        }
507    }
508
509    #[test]
510    fn unauthorized_stays_revoked_with_forbidden_rate_limit_enabled() {
511        let options = RetryHeaderOptions::new(&[FITBIT_RESET]).with_forbidden_rate_limit();
512        let map = headers(&[
513            ("retry-after", "120"),
514            (FITBIT_RESET, "45"),
515            ("x-ratelimit-remaining", "0"),
516            ("x-ratelimit-reset", "1120"),
517        ]);
518        assert_eq!(
519            classify_http_status_with_options(StatusCode::UNAUTHORIZED, &map, options),
520            RetryClass::Revoked
521        );
522    }
523
524    #[test]
525    fn forbidden_rate_limit_headers_are_ignored_by_default() {
526        let now = OffsetDateTime::from_unix_timestamp(1_000).expect("reference epoch is valid");
527        let cases = [
528            headers(&[("retry-after", "120")]),
529            headers(&[("retry-after", "Wed, 21 Oct 2026 07:30:00 GMT")]),
530            headers(&[(FITBIT_RESET, "45")]),
531            headers(&[("ratelimit-reset", "30, 3600")]),
532            headers(&[
533                ("x-ratelimit-remaining", "0"),
534                ("x-ratelimit-reset", "1120"),
535            ]),
536            headers(&[("x-ratelimit-remaining", "0"), ("x-ratelimit-reset", "880")]),
537            headers(&[("x-ratelimit-remaining", "0")]),
538            headers(&[
539                ("x-ratelimit-remaining", "0"),
540                ("x-ratelimit-reset", "later"),
541            ]),
542            headers(&[
543                ("x-ratelimit-remaining", "1"),
544                ("x-ratelimit-reset", "1120"),
545            ]),
546            headers(&[
547                ("retry-after", "120"),
548                (FITBIT_RESET, "45"),
549                ("x-ratelimit-remaining", "0"),
550                ("x-ratelimit-reset", "1120"),
551            ]),
552            HeaderMap::new(),
553        ];
554        for options in [
555            RetryHeaderOptions::default(),
556            RetryHeaderOptions::new(&[FITBIT_RESET, "ratelimit-reset"]).with_first_field(),
557        ] {
558            for map in &cases {
559                assert_eq!(
560                    classify_http_status_with_options_at(StatusCode::FORBIDDEN, map, options, now),
561                    RetryClass::Revoked,
562                    "headers: {map:?}"
563                );
564            }
565        }
566    }
567
568    #[test]
569    fn forbidden_rate_limit_delays_are_capped_for_every_header_source() {
570        let now = OffsetDateTime::from_unix_timestamp(1_000).expect("reference epoch is valid");
571        let cases = [
572            headers(&[("retry-after", "3600")]),
573            headers(&[("retry-after", "Thu, 01 Jan 1970 01:16:40 GMT")]),
574            headers(&[(FITBIT_RESET, "3600")]),
575            headers(&[
576                ("x-ratelimit-remaining", "0"),
577                ("x-ratelimit-reset", "4600"),
578            ]),
579        ];
580        for cap in [Duration::ZERO, Duration::from_secs(60)] {
581            let options = RetryHeaderOptions::new(&[FITBIT_RESET])
582                .with_forbidden_rate_limit()
583                .with_max_retry_after(cap);
584            for map in &cases {
585                assert_eq!(
586                    classify_http_status_with_options_at(StatusCode::FORBIDDEN, map, options, now),
587                    RetryClass::RetryAfter(cap),
588                    "headers: {map:?}"
589                );
590            }
591        }
592    }
593
594    #[test]
595    fn forbidden_rate_limit_keeps_vendor_parsing_and_header_precedence() {
596        let options = RetryHeaderOptions::new(&[FITBIT_RESET]).with_forbidden_rate_limit();
597        for (vendor, delay) in [("45", 45), ("later", 120)] {
598            let map = headers(&[
599                (FITBIT_RESET, vendor),
600                ("retry-after", "120"),
601                ("x-ratelimit-remaining", "0"),
602                ("x-ratelimit-reset", "0"),
603            ]);
604            assert_eq!(
605                classify_http_status_with_options(StatusCode::FORBIDDEN, &map, options),
606                RetryClass::RetryAfter(Duration::from_secs(delay))
607            );
608        }
609        let map = headers(&[("ratelimit-reset", "30, 3600")]);
610        let options = RetryHeaderOptions::new(&["ratelimit-reset"]).with_forbidden_rate_limit();
611        assert_eq!(
612            classify_http_status_with_options(StatusCode::FORBIDDEN, &map, options),
613            RetryClass::Revoked
614        );
615        assert_eq!(
616            classify_http_status_with_options(
617                StatusCode::FORBIDDEN,
618                &map,
619                options.with_first_field()
620            ),
621            RetryClass::RetryAfter(Duration::from_secs(30))
622        );
623    }
624
625    #[test]
626    fn reset_epoch_handling_does_not_change_other_statuses_or_vendor_parsing() {
627        let now = OffsetDateTime::from_unix_timestamp(1_000).expect("reference epoch is valid");
628        let map = headers(&[
629            ("x-ratelimit-remaining", "0"),
630            ("x-ratelimit-reset", "1120"),
631        ]);
632        let options = RetryHeaderOptions::default().with_forbidden_rate_limit();
633        assert_eq!(
634            classify_http_status_with_options_at(StatusCode::TOO_MANY_REQUESTS, &map, options, now),
635            RetryClass::RateLimited
636        );
637        let options = RetryHeaderOptions::new(&["x-ratelimit-reset"]).with_forbidden_rate_limit();
638        for status in [StatusCode::TOO_MANY_REQUESTS, StatusCode::FORBIDDEN] {
639            assert_eq!(
640                classify_http_status_with_options_at(status, &map, options, now),
641                RetryClass::RetryAfter(Duration::from_secs(1_120))
642            );
643        }
644    }
645
646    #[test]
647    fn timeout_statuses_are_classified_as_timeouts() {
648        for status in [StatusCode::REQUEST_TIMEOUT, StatusCode::GATEWAY_TIMEOUT] {
649            assert_eq!(
650                classify_http_status(status, &HeaderMap::new(), &[]),
651                RetryClass::Timeout
652            );
653        }
654    }
655
656    #[test]
657    fn server_errors_honor_retry_after_seconds() {
658        for code in 500..=599 {
659            let status = StatusCode::from_u16(code).expect("server status is valid");
660            assert_eq!(
661                classify_http_status(status, &headers(&[("retry-after", "7")]), &[]),
662                RetryClass::RetryAfter(Duration::from_secs(7)),
663                "status {code}"
664            );
665        }
666    }
667
668    #[test]
669    fn service_unavailable_honors_retry_after_http_date() {
670        let now = at("Wed, 21 Oct 2026 07:28:00 GMT");
671        assert_eq!(
672            classify_http_status_with_options_at(
673                StatusCode::SERVICE_UNAVAILABLE,
674                &headers(&[("retry-after", "Wed, 21 Oct 2026 07:30:00 GMT")]),
675                RetryHeaderOptions::default(),
676                now,
677            ),
678            RetryClass::RetryAfter(Duration::from_secs(120))
679        );
680    }
681
682    #[test]
683    fn server_error_retry_after_is_capped() {
684        let options = RetryHeaderOptions::default().with_max_retry_after(Duration::from_secs(30));
685        for value in ["86400", "Thu, 22 Oct 2026 07:28:00 GMT"] {
686            assert_eq!(
687                classify_http_status_with_options_at(
688                    StatusCode::SERVICE_UNAVAILABLE,
689                    &headers(&[("retry-after", value)]),
690                    options,
691                    at("Wed, 21 Oct 2026 07:28:00 GMT"),
692                ),
693                RetryClass::RetryAfter(Duration::from_secs(30))
694            );
695        }
696    }
697
698    #[test]
699    fn server_errors_without_a_usable_retry_after_keep_their_class() {
700        for code in 500..=599 {
701            let status = StatusCode::from_u16(code).expect("server status is valid");
702            let expected = if status == StatusCode::GATEWAY_TIMEOUT {
703                RetryClass::Timeout
704            } else {
705                RetryClass::Unavailable
706            };
707            for map in [HeaderMap::new(), headers(&[("retry-after", "invalid")])] {
708                assert_eq!(
709                    classify_http_status(status, &map, &[]),
710                    expected,
711                    "status {code}"
712                );
713            }
714        }
715    }
716
717    #[test]
718    fn too_early_is_retryable() {
719        let class = classify_http_status(StatusCode::TOO_EARLY, &HeaderMap::new(), &[]);
720        assert_eq!(class, RetryClass::Unavailable);
721        assert!(class.is_retryable());
722    }
723
724    #[test]
725    fn retry_after_is_uncapped_by_default() {
726        assert_eq!(
727            classify_http_status(
728                StatusCode::TOO_MANY_REQUESTS,
729                &headers(&[("retry-after", "86400")]),
730                &[],
731            ),
732            RetryClass::RetryAfter(Duration::from_secs(86_400))
733        );
734    }
735
736    #[test]
737    fn max_retry_after_clamps_longer_delays() {
738        let options = RetryHeaderOptions::default().with_max_retry_after(Duration::from_secs(300));
739        assert_eq!(
740            classify_http_status_with_options(
741                StatusCode::TOO_MANY_REQUESTS,
742                &headers(&[("retry-after", "86400")]),
743                options,
744            ),
745            RetryClass::RetryAfter(Duration::from_secs(300))
746        );
747        assert_eq!(
748            retry_after_from_headers_with_options(&headers(&[("retry-after", "120")]), options),
749            Some(Duration::from_secs(120))
750        );
751    }
752
753    #[test]
754    fn max_retry_after_clamps_http_dates_and_vendor_headers() {
755        let now = at("Wed, 21 Oct 2026 07:28:00 GMT");
756        let cap = Duration::from_secs(60);
757        let date = headers(&[("retry-after", "Thu, 22 Oct 2026 07:28:00 GMT")]);
758        assert_eq!(
759            retry_after_from_headers_with_options_at(
760                &date,
761                RetryHeaderOptions::default().with_max_retry_after(cap),
762                now,
763            ),
764            Some(cap)
765        );
766        let vendor = headers(&[(FITBIT_RESET, "3600")]);
767        assert_eq!(
768            retry_after_from_headers_with_options_at(
769                &vendor,
770                RetryHeaderOptions::new(&[FITBIT_RESET]).with_max_retry_after(cap),
771                now,
772            ),
773            Some(cap)
774        );
775    }
776
777    #[test]
778    fn rate_limit_without_a_usable_header_has_no_delay() {
779        assert_eq!(
780            classify_http_status(StatusCode::TOO_MANY_REQUESTS, &HeaderMap::new(), &[]),
781            RetryClass::RateLimited
782        );
783        assert_eq!(
784            classify_http_status(
785                StatusCode::TOO_MANY_REQUESTS,
786                &headers(&[("retry-after", "soon")]),
787                &[],
788            ),
789            RetryClass::RateLimited
790        );
791    }
792
793    #[test]
794    fn rate_limit_reads_retry_after_seconds() {
795        assert_eq!(
796            classify_http_status(
797                StatusCode::TOO_MANY_REQUESTS,
798                &headers(&[("retry-after", "120")]),
799                &[],
800            ),
801            RetryClass::RetryAfter(Duration::from_secs(120))
802        );
803    }
804
805    #[test]
806    fn retry_after_accepts_an_http_date() {
807        let now = at("Wed, 21 Oct 2026 07:28:00 GMT");
808        let map = headers(&[("retry-after", "Wed, 21 Oct 2026 07:30:00 GMT")]);
809        assert_eq!(
810            retry_after_from_headers_at(&map, &[], now),
811            Some(Duration::from_secs(120))
812        );
813    }
814
815    #[test]
816    fn a_retry_after_date_in_the_past_means_retry_now() {
817        let now = at("Wed, 21 Oct 2026 07:28:00 GMT");
818        let map = headers(&[("retry-after", "Wed, 21 Oct 2026 07:00:00 GMT")]);
819        assert_eq!(
820            retry_after_from_headers_at(&map, &[], now),
821            Some(Duration::ZERO)
822        );
823    }
824
825    #[test]
826    fn vendor_header_wins_over_retry_after() {
827        let map = headers(&[(FITBIT_RESET, "45"), ("retry-after", "120")]);
828        assert_eq!(
829            classify_http_status(StatusCode::TOO_MANY_REQUESTS, &map, &[FITBIT_RESET]),
830            RetryClass::RetryAfter(Duration::from_secs(45))
831        );
832        assert_eq!(
833            classify_http_status(StatusCode::TOO_MANY_REQUESTS, &map, &[]),
834            RetryClass::RetryAfter(Duration::from_secs(120))
835        );
836    }
837
838    #[test]
839    fn an_unparseable_vendor_header_falls_through_to_retry_after() {
840        let map = headers(&[(FITBIT_RESET, "later"), ("retry-after", "9")]);
841        assert_eq!(
842            classify_http_status(StatusCode::TOO_MANY_REQUESTS, &map, &[FITBIT_RESET]),
843            RetryClass::RetryAfter(Duration::from_secs(9))
844        );
845    }
846
847    #[test]
848    fn comma_separated_vendor_header_is_unchanged_by_default() {
849        let map = headers(&[("ratelimit-reset", "30, 3600")]);
850        assert_eq!(
851            classify_http_status(StatusCode::TOO_MANY_REQUESTS, &map, &["ratelimit-reset"],),
852            RetryClass::RateLimited
853        );
854    }
855
856    #[test]
857    fn first_field_option_reads_a_comma_separated_vendor_header() {
858        let map = headers(&[("ratelimit-reset", "30, 3600")]);
859        let options = RetryHeaderOptions::new(&["ratelimit-reset"]).with_first_field();
860        assert_eq!(
861            classify_http_status_with_options(StatusCode::TOO_MANY_REQUESTS, &map, options),
862            RetryClass::RetryAfter(Duration::from_secs(30))
863        );
864    }
865
866    #[test]
867    fn first_field_option_trims_whitespace() {
868        let map = headers(&[("ratelimit-reset", " 30 ,  3600 ")]);
869        let options = RetryHeaderOptions::new(&["ratelimit-reset"]).with_first_field();
870        assert_eq!(
871            retry_after_from_headers_with_options(&map, options),
872            Some(Duration::from_secs(30))
873        );
874    }
875
876    #[test]
877    fn first_field_option_accepts_a_single_value() {
878        let map = headers(&[("ratelimit-reset", "30")]);
879        let options = RetryHeaderOptions::new(&["ratelimit-reset"]).with_first_field();
880        assert_eq!(
881            retry_after_from_headers_with_options(&map, options),
882            Some(Duration::from_secs(30))
883        );
884    }
885
886    #[test]
887    fn first_field_option_does_not_split_retry_after_http_dates() {
888        let now = at("Wed, 21 Oct 2026 07:28:00 GMT");
889        let map = headers(&[("retry-after", "Wed, 21 Oct 2026 07:30:00 GMT")]);
890        let options = RetryHeaderOptions::default().with_first_field();
891        assert_eq!(
892            retry_after_from_headers_with_options_at(&map, options, now),
893            Some(Duration::from_secs(120))
894        );
895    }
896
897    #[test]
898    fn an_invalid_extra_header_name_is_skipped() {
899        let map = headers(&[("retry-after", "5")]);
900        assert_eq!(
901            classify_http_status(StatusCode::TOO_MANY_REQUESTS, &map, &["not a header"]),
902            RetryClass::RetryAfter(Duration::from_secs(5))
903        );
904    }
905
906    #[test]
907    fn other_server_errors_are_unavailable() {
908        for status in [
909            StatusCode::INTERNAL_SERVER_ERROR,
910            StatusCode::BAD_GATEWAY,
911            StatusCode::SERVICE_UNAVAILABLE,
912        ] {
913            assert_eq!(
914                classify_http_status(status, &HeaderMap::new(), &[]),
915                RetryClass::Unavailable
916            );
917        }
918    }
919
920    #[test]
921    fn other_statuses_are_permanent() {
922        for status in [
923            StatusCode::BAD_REQUEST,
924            StatusCode::NOT_FOUND,
925            StatusCode::UNPROCESSABLE_ENTITY,
926            StatusCode::OK,
927        ] {
928            assert_eq!(
929                classify_http_status(status, &HeaderMap::new(), &[]),
930                RetryClass::Permanent
931            );
932        }
933    }
934
935    #[test]
936    fn transport_errors_split_timeouts_from_connection_failures() {
937        assert_eq!(classify_transport_error(true), RetryClass::Timeout);
938        assert_eq!(classify_transport_error(false), RetryClass::Unavailable);
939    }
940
941    #[test]
942    fn retryability_and_delay_are_exposed_per_class() {
943        assert!(RetryClass::RetryAfter(Duration::from_secs(1)).is_retryable());
944        assert!(RetryClass::RateLimited.is_retryable());
945        assert!(RetryClass::Unavailable.is_retryable());
946        assert!(RetryClass::Timeout.is_retryable());
947        assert!(!RetryClass::Revoked.is_retryable());
948        assert!(!RetryClass::Permanent.is_retryable());
949
950        assert_eq!(
951            RetryClass::RetryAfter(Duration::from_secs(3)).retry_after(),
952            Some(Duration::from_secs(3))
953        );
954        assert_eq!(RetryClass::RateLimited.retry_after(), None);
955    }
956}