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    /// This is a rate limit that 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` and `504` are [`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 other `5xx` is [`RetryClass::Unavailable`].
139/// - Everything else, success codes included, is [`RetryClass::Permanent`];
140///   callers only reach this function for responses they already treat as
141///   failures.
142///
143/// `extra_retry_after_headers` names vendor headers to consult *before* the
144/// standard `Retry-After`, in the order given. Fitbit, for instance, sends the
145/// remaining window in `fitbit-rate-limit-reset`, so that integration passes
146/// `&["fitbit-rate-limit-reset"]` while every other one passes `&[]`. Names that
147/// are not valid header names are skipped.
148///
149/// # Example
150///
151/// ```rust
152/// use std::time::Duration;
153///
154/// use baukit_http::{RetryClass, classify_http_status};
155/// use axum::http::{HeaderMap, HeaderValue, StatusCode, header::RETRY_AFTER};
156///
157/// let mut headers = HeaderMap::new();
158/// headers.insert(RETRY_AFTER, HeaderValue::from_static("30"));
159/// assert_eq!(
160///     classify_http_status(StatusCode::TOO_MANY_REQUESTS, &headers, &[]),
161///     RetryClass::RetryAfter(Duration::from_secs(30))
162/// );
163/// assert_eq!(
164///     classify_http_status(StatusCode::FORBIDDEN, &HeaderMap::new(), &[]),
165///     RetryClass::Revoked
166/// );
167/// ```
168pub fn classify_http_status(
169    status: StatusCode,
170    headers: &HeaderMap,
171    extra_retry_after_headers: &[&str],
172) -> RetryClass {
173    classify_http_status_with_options(
174        status,
175        headers,
176        RetryHeaderOptions::new(extra_retry_after_headers),
177    )
178}
179
180/// Classifies an upstream HTTP response with configurable retry header parsing.
181///
182/// See [`classify_http_status`] for the status mapping. The options control
183/// which vendor retry headers are checked, whether their first
184/// comma-separated field is used, and the largest delay reported.
185/// [`RetryHeaderOptions::with_forbidden_rate_limit`] also recognizes rate
186/// limits on `403` responses. `401` always stays [`RetryClass::Revoked`].
187pub fn classify_http_status_with_options(
188    status: StatusCode,
189    headers: &HeaderMap,
190    options: RetryHeaderOptions<'_>,
191) -> RetryClass {
192    classify_http_status_with_options_at(status, headers, options, OffsetDateTime::now_utc())
193}
194
195/// Classifies an upstream HTTP response using `options` at `now`.
196///
197/// See [`classify_http_status_with_options`] for the status mapping.
198/// `now` is the reference instant for HTTP-date retry headers and the
199/// `x-ratelimit-reset` epoch on opted-in `403` responses. Past deadlines
200/// yield [`Duration::ZERO`], and delays are clamped to the configured cap.
201pub fn classify_http_status_with_options_at(
202    status: StatusCode,
203    headers: &HeaderMap,
204    options: RetryHeaderOptions<'_>,
205    now: OffsetDateTime,
206) -> RetryClass {
207    match status {
208        StatusCode::FORBIDDEN if options.forbidden_rate_limit => {
209            classify_forbidden_rate_limit(headers, options, now)
210        }
211        StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => RetryClass::Revoked,
212        StatusCode::REQUEST_TIMEOUT | StatusCode::GATEWAY_TIMEOUT => RetryClass::Timeout,
213        StatusCode::TOO_EARLY => RetryClass::Unavailable,
214        StatusCode::TOO_MANY_REQUESTS => {
215            retry_after_from_headers_with_options_at(headers, options, now)
216                .map_or(RetryClass::RateLimited, RetryClass::RetryAfter)
217        }
218        _ if status.is_server_error() => RetryClass::Unavailable,
219        _ => RetryClass::Permanent,
220    }
221}
222
223fn classify_forbidden_rate_limit(
224    headers: &HeaderMap,
225    options: RetryHeaderOptions<'_>,
226    now: OffsetDateTime,
227) -> RetryClass {
228    if let Some(delay) = retry_after_from_headers_with_options_at(headers, options, now) {
229        return RetryClass::RetryAfter(delay);
230    }
231    let remaining = headers
232        .get("x-ratelimit-remaining")
233        .and_then(|value| value.to_str().ok());
234    if remaining.map(str::trim) != Some("0") {
235        return RetryClass::Revoked;
236    }
237    let reset = headers
238        .get("x-ratelimit-reset")
239        .and_then(|value| value.to_str().ok())
240        .and_then(|value| value.trim().parse::<i64>().ok())
241        .and_then(|epoch| OffsetDateTime::from_unix_timestamp(epoch).ok());
242    reset.map_or(RetryClass::RateLimited, |deadline| {
243        let delay = (deadline - now).try_into().unwrap_or(Duration::ZERO);
244        RetryClass::RetryAfter(options.cap(delay))
245    })
246}
247
248/// Reads the retry delay from the vendor headers first, then `Retry-After`.
249///
250/// Both `Retry-After` forms are accepted: delay seconds (`Retry-After: 120`)
251/// and an HTTP-date (`Retry-After: Wed, 21 Oct 2026 07:28:00 GMT`). A date in
252/// the past yields [`Duration::ZERO`], meaning retry now. Values that parse as
253/// neither are ignored, and the next candidate header is tried.
254///
255/// `now` is the reference instant for HTTP-date values; pass
256/// [`OffsetDateTime::now_utc`].
257pub fn retry_after_from_headers_at(
258    headers: &HeaderMap,
259    extra_retry_after_headers: &[&str],
260    now: OffsetDateTime,
261) -> Option<Duration> {
262    retry_after_from_headers_with_options_at(
263        headers,
264        RetryHeaderOptions::new(extra_retry_after_headers),
265        now,
266    )
267}
268
269/// Reads a retry delay using configurable vendor header parsing at `now`.
270///
271/// Vendor headers are checked before `Retry-After`. Invalid names and values
272/// are skipped. The standard header always keeps its whole value so HTTP dates
273/// continue to parse when first-field handling is enabled for vendor headers.
274/// A delay above [`RetryHeaderOptions::with_max_retry_after`] is clamped.
275pub fn retry_after_from_headers_with_options_at(
276    headers: &HeaderMap,
277    options: RetryHeaderOptions<'_>,
278    now: OffsetDateTime,
279) -> Option<Duration> {
280    let vendor_delay = options
281        .extra_retry_after_headers
282        .iter()
283        .filter_map(|name| HeaderName::try_from(*name).ok())
284        .filter_map(|name| headers.get(&name).and_then(|value| value.to_str().ok()))
285        .find_map(|value| {
286            let value = if options.first_field {
287                value.split(',').next().unwrap_or(value)
288            } else {
289                value
290            };
291            parse_retry_after(value, now)
292        });
293
294    vendor_delay
295        .or_else(|| {
296            headers
297                .get(RETRY_AFTER)
298                .and_then(|value| value.to_str().ok())
299                .and_then(|value| parse_retry_after(value, now))
300        })
301        .map(|delay| options.cap(delay))
302}
303
304/// Reads the retry delay relative to the current time.
305///
306/// See [`retry_after_from_headers_at`] for the accepted formats.
307pub fn retry_after_from_headers(
308    headers: &HeaderMap,
309    extra_retry_after_headers: &[&str],
310) -> Option<Duration> {
311    retry_after_from_headers_at(
312        headers,
313        extra_retry_after_headers,
314        OffsetDateTime::now_utc(),
315    )
316}
317
318/// Reads a retry delay relative to the current time using `options`.
319///
320/// See [`retry_after_from_headers_with_options_at`] for the accepted formats.
321pub fn retry_after_from_headers_with_options(
322    headers: &HeaderMap,
323    options: RetryHeaderOptions<'_>,
324) -> Option<Duration> {
325    retry_after_from_headers_with_options_at(headers, options, OffsetDateTime::now_utc())
326}
327
328/// Classifies a transport failure that never produced a response.
329///
330/// HTTP clients report timeouts differently, so the caller decides: pass
331/// `is_timeout` from the client's own predicate, for example
332/// `reqwest::Error::is_timeout`. Everything else is treated as a transient
333/// connection problem.
334pub const fn classify_transport_error(is_timeout: bool) -> RetryClass {
335    if is_timeout {
336        RetryClass::Timeout
337    } else {
338        RetryClass::Unavailable
339    }
340}
341
342fn parse_retry_after(value: &str, now: OffsetDateTime) -> Option<Duration> {
343    let value = value.trim();
344    if let Ok(seconds) = value.parse::<u64>() {
345        return Some(Duration::from_secs(seconds));
346    }
347    let deadline = OffsetDateTime::parse(value, &Rfc2822).ok()?;
348    Some((deadline - now).try_into().unwrap_or(Duration::ZERO))
349}
350
351#[cfg(test)]
352mod tests {
353    use axum::http::HeaderValue;
354
355    use super::*;
356
357    const FITBIT_RESET: &str = "fitbit-rate-limit-reset";
358
359    fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
360        let mut map = HeaderMap::new();
361        for (name, value) in pairs {
362            map.insert(
363                HeaderName::try_from(*name).expect("header name should be valid"),
364                HeaderValue::from_str(value).expect("header value should be valid"),
365            );
366        }
367        map
368    }
369
370    fn at(text: &str) -> OffsetDateTime {
371        OffsetDateTime::parse(text, &Rfc2822).expect("reference date should parse")
372    }
373
374    #[test]
375    fn credentials_rejected_by_upstream_are_revoked() {
376        for status in [StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN] {
377            assert_eq!(
378                classify_http_status(status, &HeaderMap::new(), &[]),
379                RetryClass::Revoked
380            );
381        }
382    }
383
384    #[test]
385    fn forbidden_rate_limit_reads_retry_after_seconds() {
386        let options = RetryHeaderOptions::default().with_forbidden_rate_limit();
387        for seconds in [0, 120, 86_400] {
388            assert_eq!(
389                classify_http_status_with_options(
390                    StatusCode::FORBIDDEN,
391                    &headers(&[("retry-after", &seconds.to_string())]),
392                    options,
393                ),
394                RetryClass::RetryAfter(Duration::from_secs(seconds))
395            );
396        }
397    }
398
399    #[test]
400    fn forbidden_rate_limit_reads_retry_after_http_dates() {
401        let now = at("Wed, 21 Oct 2026 07:28:00 GMT");
402        let options = RetryHeaderOptions::default().with_forbidden_rate_limit();
403        let cases = [
404            ("Wed, 21 Oct 2026 07:30:00 GMT", Duration::from_secs(120)),
405            ("Wed, 21 Oct 2026 07:00:00 GMT", Duration::ZERO),
406        ];
407        for (date, delay) in cases {
408            assert_eq!(
409                classify_http_status_with_options_at(
410                    StatusCode::FORBIDDEN,
411                    &headers(&[("retry-after", date)]),
412                    options,
413                    now,
414                ),
415                RetryClass::RetryAfter(delay)
416            );
417        }
418    }
419
420    #[test]
421    fn forbidden_rate_limit_reads_reset_epochs_and_saturates_past_resets() {
422        let now = OffsetDateTime::from_unix_timestamp(1_000).expect("reference epoch is valid");
423        let options = RetryHeaderOptions::default().with_forbidden_rate_limit();
424        for (reset, delay) in [
425            ("1120", Duration::from_secs(120)),
426            ("1000", Duration::ZERO),
427            ("880", Duration::ZERO),
428            ("-1", Duration::ZERO),
429        ] {
430            assert_eq!(
431                classify_http_status_with_options_at(
432                    StatusCode::FORBIDDEN,
433                    &headers(&[("x-ratelimit-remaining", "0"), ("x-ratelimit-reset", reset)]),
434                    options,
435                    now,
436                ),
437                RetryClass::RetryAfter(delay),
438                "reset: {reset}"
439            );
440        }
441    }
442
443    #[test]
444    fn forbidden_rate_limit_without_a_usable_reset_has_no_delay() {
445        let now = OffsetDateTime::from_unix_timestamp(1_000).expect("reference epoch is valid");
446        let options = RetryHeaderOptions::default().with_forbidden_rate_limit();
447        for reset in [
448            None,
449            Some("later"),
450            Some("Wed, 21 Oct 2026 07:30:00 GMT"),
451            Some("1120.5"),
452            Some("9223372036854775807"),
453            Some("18446744073709551616"),
454        ] {
455            let mut map = headers(&[("x-ratelimit-remaining", "0"), ("retry-after", "soon")]);
456            if let Some(reset) = reset {
457                map.insert(
458                    "x-ratelimit-reset",
459                    HeaderValue::from_str(reset).expect("reset should be a valid header value"),
460                );
461            }
462            assert_eq!(
463                classify_http_status_with_options_at(StatusCode::FORBIDDEN, &map, options, now),
464                RetryClass::RateLimited,
465                "reset: {reset:?}"
466            );
467        }
468    }
469
470    #[test]
471    fn forbidden_without_rate_limit_evidence_stays_revoked() {
472        let now = OffsetDateTime::from_unix_timestamp(1_000).expect("reference epoch is valid");
473        let options = RetryHeaderOptions::default().with_forbidden_rate_limit();
474        let cases = [
475            HeaderMap::new(),
476            headers(&[("retry-after", "soon")]),
477            headers(&[("x-ratelimit-reset", "1120")]),
478            headers(&[
479                ("x-ratelimit-remaining", "1"),
480                ("x-ratelimit-reset", "1120"),
481            ]),
482            headers(&[
483                ("x-ratelimit-remaining", "invalid"),
484                ("x-ratelimit-reset", "1120"),
485            ]),
486        ];
487        for map in cases {
488            assert_eq!(
489                classify_http_status_with_options_at(StatusCode::FORBIDDEN, &map, options, now),
490                RetryClass::Revoked,
491                "headers: {map:?}"
492            );
493        }
494    }
495
496    #[test]
497    fn unauthorized_stays_revoked_with_forbidden_rate_limit_enabled() {
498        let options = RetryHeaderOptions::new(&[FITBIT_RESET]).with_forbidden_rate_limit();
499        let map = headers(&[
500            ("retry-after", "120"),
501            (FITBIT_RESET, "45"),
502            ("x-ratelimit-remaining", "0"),
503            ("x-ratelimit-reset", "1120"),
504        ]);
505        assert_eq!(
506            classify_http_status_with_options(StatusCode::UNAUTHORIZED, &map, options),
507            RetryClass::Revoked
508        );
509    }
510
511    #[test]
512    fn forbidden_rate_limit_headers_are_ignored_by_default() {
513        let now = OffsetDateTime::from_unix_timestamp(1_000).expect("reference epoch is valid");
514        let cases = [
515            headers(&[("retry-after", "120")]),
516            headers(&[("retry-after", "Wed, 21 Oct 2026 07:30:00 GMT")]),
517            headers(&[(FITBIT_RESET, "45")]),
518            headers(&[("ratelimit-reset", "30, 3600")]),
519            headers(&[
520                ("x-ratelimit-remaining", "0"),
521                ("x-ratelimit-reset", "1120"),
522            ]),
523            headers(&[("x-ratelimit-remaining", "0"), ("x-ratelimit-reset", "880")]),
524            headers(&[("x-ratelimit-remaining", "0")]),
525            headers(&[
526                ("x-ratelimit-remaining", "0"),
527                ("x-ratelimit-reset", "later"),
528            ]),
529            headers(&[
530                ("x-ratelimit-remaining", "1"),
531                ("x-ratelimit-reset", "1120"),
532            ]),
533            headers(&[
534                ("retry-after", "120"),
535                (FITBIT_RESET, "45"),
536                ("x-ratelimit-remaining", "0"),
537                ("x-ratelimit-reset", "1120"),
538            ]),
539            HeaderMap::new(),
540        ];
541        for options in [
542            RetryHeaderOptions::default(),
543            RetryHeaderOptions::new(&[FITBIT_RESET, "ratelimit-reset"]).with_first_field(),
544        ] {
545            for map in &cases {
546                assert_eq!(
547                    classify_http_status_with_options_at(StatusCode::FORBIDDEN, map, options, now),
548                    RetryClass::Revoked,
549                    "headers: {map:?}"
550                );
551            }
552        }
553    }
554
555    #[test]
556    fn forbidden_rate_limit_delays_are_capped_for_every_header_source() {
557        let now = OffsetDateTime::from_unix_timestamp(1_000).expect("reference epoch is valid");
558        let cases = [
559            headers(&[("retry-after", "3600")]),
560            headers(&[("retry-after", "Thu, 01 Jan 1970 01:16:40 GMT")]),
561            headers(&[(FITBIT_RESET, "3600")]),
562            headers(&[
563                ("x-ratelimit-remaining", "0"),
564                ("x-ratelimit-reset", "4600"),
565            ]),
566        ];
567        for cap in [Duration::ZERO, Duration::from_secs(60)] {
568            let options = RetryHeaderOptions::new(&[FITBIT_RESET])
569                .with_forbidden_rate_limit()
570                .with_max_retry_after(cap);
571            for map in &cases {
572                assert_eq!(
573                    classify_http_status_with_options_at(StatusCode::FORBIDDEN, map, options, now),
574                    RetryClass::RetryAfter(cap),
575                    "headers: {map:?}"
576                );
577            }
578        }
579    }
580
581    #[test]
582    fn forbidden_rate_limit_keeps_vendor_parsing_and_header_precedence() {
583        let options = RetryHeaderOptions::new(&[FITBIT_RESET]).with_forbidden_rate_limit();
584        for (vendor, delay) in [("45", 45), ("later", 120)] {
585            let map = headers(&[
586                (FITBIT_RESET, vendor),
587                ("retry-after", "120"),
588                ("x-ratelimit-remaining", "0"),
589                ("x-ratelimit-reset", "0"),
590            ]);
591            assert_eq!(
592                classify_http_status_with_options(StatusCode::FORBIDDEN, &map, options),
593                RetryClass::RetryAfter(Duration::from_secs(delay))
594            );
595        }
596        let map = headers(&[("ratelimit-reset", "30, 3600")]);
597        let options = RetryHeaderOptions::new(&["ratelimit-reset"]).with_forbidden_rate_limit();
598        assert_eq!(
599            classify_http_status_with_options(StatusCode::FORBIDDEN, &map, options),
600            RetryClass::Revoked
601        );
602        assert_eq!(
603            classify_http_status_with_options(
604                StatusCode::FORBIDDEN,
605                &map,
606                options.with_first_field()
607            ),
608            RetryClass::RetryAfter(Duration::from_secs(30))
609        );
610    }
611
612    #[test]
613    fn reset_epoch_handling_does_not_change_other_statuses_or_vendor_parsing() {
614        let now = OffsetDateTime::from_unix_timestamp(1_000).expect("reference epoch is valid");
615        let map = headers(&[
616            ("x-ratelimit-remaining", "0"),
617            ("x-ratelimit-reset", "1120"),
618        ]);
619        let options = RetryHeaderOptions::default().with_forbidden_rate_limit();
620        assert_eq!(
621            classify_http_status_with_options_at(StatusCode::TOO_MANY_REQUESTS, &map, options, now),
622            RetryClass::RateLimited
623        );
624        let options = RetryHeaderOptions::new(&["x-ratelimit-reset"]).with_forbidden_rate_limit();
625        for status in [StatusCode::TOO_MANY_REQUESTS, StatusCode::FORBIDDEN] {
626            assert_eq!(
627                classify_http_status_with_options_at(status, &map, options, now),
628                RetryClass::RetryAfter(Duration::from_secs(1_120))
629            );
630        }
631    }
632
633    #[test]
634    fn timeout_statuses_are_classified_as_timeouts() {
635        for status in [StatusCode::REQUEST_TIMEOUT, StatusCode::GATEWAY_TIMEOUT] {
636            assert_eq!(
637                classify_http_status(status, &HeaderMap::new(), &[]),
638                RetryClass::Timeout
639            );
640        }
641    }
642
643    #[test]
644    fn too_early_is_retryable() {
645        let class = classify_http_status(StatusCode::TOO_EARLY, &HeaderMap::new(), &[]);
646        assert_eq!(class, RetryClass::Unavailable);
647        assert!(class.is_retryable());
648    }
649
650    #[test]
651    fn retry_after_is_uncapped_by_default() {
652        assert_eq!(
653            classify_http_status(
654                StatusCode::TOO_MANY_REQUESTS,
655                &headers(&[("retry-after", "86400")]),
656                &[],
657            ),
658            RetryClass::RetryAfter(Duration::from_secs(86_400))
659        );
660    }
661
662    #[test]
663    fn max_retry_after_clamps_longer_delays() {
664        let options = RetryHeaderOptions::default().with_max_retry_after(Duration::from_secs(300));
665        assert_eq!(
666            classify_http_status_with_options(
667                StatusCode::TOO_MANY_REQUESTS,
668                &headers(&[("retry-after", "86400")]),
669                options,
670            ),
671            RetryClass::RetryAfter(Duration::from_secs(300))
672        );
673        assert_eq!(
674            retry_after_from_headers_with_options(&headers(&[("retry-after", "120")]), options),
675            Some(Duration::from_secs(120))
676        );
677    }
678
679    #[test]
680    fn max_retry_after_clamps_http_dates_and_vendor_headers() {
681        let now = at("Wed, 21 Oct 2026 07:28:00 GMT");
682        let cap = Duration::from_secs(60);
683        let date = headers(&[("retry-after", "Thu, 22 Oct 2026 07:28:00 GMT")]);
684        assert_eq!(
685            retry_after_from_headers_with_options_at(
686                &date,
687                RetryHeaderOptions::default().with_max_retry_after(cap),
688                now,
689            ),
690            Some(cap)
691        );
692        let vendor = headers(&[(FITBIT_RESET, "3600")]);
693        assert_eq!(
694            retry_after_from_headers_with_options_at(
695                &vendor,
696                RetryHeaderOptions::new(&[FITBIT_RESET]).with_max_retry_after(cap),
697                now,
698            ),
699            Some(cap)
700        );
701    }
702
703    #[test]
704    fn rate_limit_without_a_usable_header_has_no_delay() {
705        assert_eq!(
706            classify_http_status(StatusCode::TOO_MANY_REQUESTS, &HeaderMap::new(), &[]),
707            RetryClass::RateLimited
708        );
709        assert_eq!(
710            classify_http_status(
711                StatusCode::TOO_MANY_REQUESTS,
712                &headers(&[("retry-after", "soon")]),
713                &[],
714            ),
715            RetryClass::RateLimited
716        );
717    }
718
719    #[test]
720    fn rate_limit_reads_retry_after_seconds() {
721        assert_eq!(
722            classify_http_status(
723                StatusCode::TOO_MANY_REQUESTS,
724                &headers(&[("retry-after", "120")]),
725                &[],
726            ),
727            RetryClass::RetryAfter(Duration::from_secs(120))
728        );
729    }
730
731    #[test]
732    fn retry_after_accepts_an_http_date() {
733        let now = at("Wed, 21 Oct 2026 07:28:00 GMT");
734        let map = headers(&[("retry-after", "Wed, 21 Oct 2026 07:30:00 GMT")]);
735        assert_eq!(
736            retry_after_from_headers_at(&map, &[], now),
737            Some(Duration::from_secs(120))
738        );
739    }
740
741    #[test]
742    fn a_retry_after_date_in_the_past_means_retry_now() {
743        let now = at("Wed, 21 Oct 2026 07:28:00 GMT");
744        let map = headers(&[("retry-after", "Wed, 21 Oct 2026 07:00:00 GMT")]);
745        assert_eq!(
746            retry_after_from_headers_at(&map, &[], now),
747            Some(Duration::ZERO)
748        );
749    }
750
751    #[test]
752    fn vendor_header_wins_over_retry_after() {
753        let map = headers(&[(FITBIT_RESET, "45"), ("retry-after", "120")]);
754        assert_eq!(
755            classify_http_status(StatusCode::TOO_MANY_REQUESTS, &map, &[FITBIT_RESET]),
756            RetryClass::RetryAfter(Duration::from_secs(45))
757        );
758        assert_eq!(
759            classify_http_status(StatusCode::TOO_MANY_REQUESTS, &map, &[]),
760            RetryClass::RetryAfter(Duration::from_secs(120))
761        );
762    }
763
764    #[test]
765    fn an_unparseable_vendor_header_falls_through_to_retry_after() {
766        let map = headers(&[(FITBIT_RESET, "later"), ("retry-after", "9")]);
767        assert_eq!(
768            classify_http_status(StatusCode::TOO_MANY_REQUESTS, &map, &[FITBIT_RESET]),
769            RetryClass::RetryAfter(Duration::from_secs(9))
770        );
771    }
772
773    #[test]
774    fn comma_separated_vendor_header_is_unchanged_by_default() {
775        let map = headers(&[("ratelimit-reset", "30, 3600")]);
776        assert_eq!(
777            classify_http_status(StatusCode::TOO_MANY_REQUESTS, &map, &["ratelimit-reset"],),
778            RetryClass::RateLimited
779        );
780    }
781
782    #[test]
783    fn first_field_option_reads_a_comma_separated_vendor_header() {
784        let map = headers(&[("ratelimit-reset", "30, 3600")]);
785        let options = RetryHeaderOptions::new(&["ratelimit-reset"]).with_first_field();
786        assert_eq!(
787            classify_http_status_with_options(StatusCode::TOO_MANY_REQUESTS, &map, options),
788            RetryClass::RetryAfter(Duration::from_secs(30))
789        );
790    }
791
792    #[test]
793    fn first_field_option_trims_whitespace() {
794        let map = headers(&[("ratelimit-reset", " 30 ,  3600 ")]);
795        let options = RetryHeaderOptions::new(&["ratelimit-reset"]).with_first_field();
796        assert_eq!(
797            retry_after_from_headers_with_options(&map, options),
798            Some(Duration::from_secs(30))
799        );
800    }
801
802    #[test]
803    fn first_field_option_accepts_a_single_value() {
804        let map = headers(&[("ratelimit-reset", "30")]);
805        let options = RetryHeaderOptions::new(&["ratelimit-reset"]).with_first_field();
806        assert_eq!(
807            retry_after_from_headers_with_options(&map, options),
808            Some(Duration::from_secs(30))
809        );
810    }
811
812    #[test]
813    fn first_field_option_does_not_split_retry_after_http_dates() {
814        let now = at("Wed, 21 Oct 2026 07:28:00 GMT");
815        let map = headers(&[("retry-after", "Wed, 21 Oct 2026 07:30:00 GMT")]);
816        let options = RetryHeaderOptions::default().with_first_field();
817        assert_eq!(
818            retry_after_from_headers_with_options_at(&map, options, now),
819            Some(Duration::from_secs(120))
820        );
821    }
822
823    #[test]
824    fn an_invalid_extra_header_name_is_skipped() {
825        let map = headers(&[("retry-after", "5")]);
826        assert_eq!(
827            classify_http_status(StatusCode::TOO_MANY_REQUESTS, &map, &["not a header"]),
828            RetryClass::RetryAfter(Duration::from_secs(5))
829        );
830    }
831
832    #[test]
833    fn other_server_errors_are_unavailable() {
834        for status in [
835            StatusCode::INTERNAL_SERVER_ERROR,
836            StatusCode::BAD_GATEWAY,
837            StatusCode::SERVICE_UNAVAILABLE,
838        ] {
839            assert_eq!(
840                classify_http_status(status, &HeaderMap::new(), &[]),
841                RetryClass::Unavailable
842            );
843        }
844    }
845
846    #[test]
847    fn other_statuses_are_permanent() {
848        for status in [
849            StatusCode::BAD_REQUEST,
850            StatusCode::NOT_FOUND,
851            StatusCode::UNPROCESSABLE_ENTITY,
852            StatusCode::OK,
853        ] {
854            assert_eq!(
855                classify_http_status(status, &HeaderMap::new(), &[]),
856                RetryClass::Permanent
857            );
858        }
859    }
860
861    #[test]
862    fn transport_errors_split_timeouts_from_connection_failures() {
863        assert_eq!(classify_transport_error(true), RetryClass::Timeout);
864        assert_eq!(classify_transport_error(false), RetryClass::Unavailable);
865    }
866
867    #[test]
868    fn retryability_and_delay_are_exposed_per_class() {
869        assert!(RetryClass::RetryAfter(Duration::from_secs(1)).is_retryable());
870        assert!(RetryClass::RateLimited.is_retryable());
871        assert!(RetryClass::Unavailable.is_retryable());
872        assert!(RetryClass::Timeout.is_retryable());
873        assert!(!RetryClass::Revoked.is_retryable());
874        assert!(!RetryClass::Permanent.is_retryable());
875
876        assert_eq!(
877            RetryClass::RetryAfter(Duration::from_secs(3)).retry_after(),
878            Some(Duration::from_secs(3))
879        );
880        assert_eq!(RetryClass::RateLimited.retry_after(), None);
881    }
882}