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-After` value.
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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
45pub struct RetryHeaderOptions<'a> {
46    extra_retry_after_headers: &'a [&'a str],
47    first_field: bool,
48    max_retry_after: Option<Duration>,
49}
50
51impl<'a> RetryHeaderOptions<'a> {
52    /// Creates options that consult the named vendor headers in order.
53    pub const fn new(extra_retry_after_headers: &'a [&'a str]) -> Self {
54        Self {
55            extra_retry_after_headers,
56            first_field: false,
57            max_retry_after: None,
58        }
59    }
60
61    /// Parses only the first comma-separated field of vendor header values.
62    ///
63    /// This does not split `Retry-After`, whose HTTP-date form contains a
64    /// comma.
65    #[must_use]
66    pub const fn with_first_field(mut self) -> Self {
67        self.first_field = true;
68        self
69    }
70
71    /// Clamps parsed delays to `max`.
72    ///
73    /// An upstream that sends `Retry-After: 86400` then yields `max` instead of
74    /// stalling a worker for a day.
75    #[must_use]
76    pub const fn with_max_retry_after(mut self, max: Duration) -> Self {
77        self.max_retry_after = Some(max);
78        self
79    }
80
81    fn cap(self, delay: Duration) -> Duration {
82        match self.max_retry_after {
83            Some(max) => delay.min(max),
84            None => delay,
85        }
86    }
87}
88
89impl RetryClass {
90    /// Returns whether retrying the same request can succeed.
91    ///
92    /// [`RetryClass::Revoked`] and [`RetryClass::Permanent`] return `false`:
93    /// both need an operator or a re-authorization, not another attempt.
94    pub const fn is_retryable(self) -> bool {
95        matches!(
96            self,
97            Self::RetryAfter(_) | Self::RateLimited | Self::Unavailable | Self::Timeout
98        )
99    }
100
101    /// Returns the wait the upstream asked for, if it named one.
102    pub const fn retry_after(self) -> Option<Duration> {
103        match self {
104            Self::RetryAfter(delay) => Some(delay),
105            _ => None,
106        }
107    }
108}
109
110/// Classifies an upstream HTTP response by status and headers.
111///
112/// The mapping is:
113///
114/// - `401` and `403` are [`RetryClass::Revoked`]; the credential needs renewing.
115/// - `408` and `504` are [`RetryClass::Timeout`].
116/// - `425` is [`RetryClass::Unavailable`]. RFC 8470 section 5.2 lets a client
117///   retry once the request is no longer sent as early data.
118/// - `429` is [`RetryClass::RetryAfter`] when a retry-after header parses, and
119///   [`RetryClass::RateLimited`] otherwise. The delay is uncapped; see
120///   [`RetryHeaderOptions::with_max_retry_after`].
121/// - Any other `5xx` is [`RetryClass::Unavailable`].
122/// - Everything else, success codes included, is [`RetryClass::Permanent`];
123///   callers only reach this function for responses they already treat as
124///   failures.
125///
126/// `extra_retry_after_headers` names vendor headers to consult *before* the
127/// standard `Retry-After`, in the order given. Fitbit, for instance, sends the
128/// remaining window in `fitbit-rate-limit-reset`, so that integration passes
129/// `&["fitbit-rate-limit-reset"]` while every other one passes `&[]`. Names that
130/// are not valid header names are skipped.
131///
132/// # Example
133///
134/// ```rust
135/// use std::time::Duration;
136///
137/// use baukit_http::{RetryClass, classify_http_status};
138/// use axum::http::{HeaderMap, HeaderValue, StatusCode, header::RETRY_AFTER};
139///
140/// let mut headers = HeaderMap::new();
141/// headers.insert(RETRY_AFTER, HeaderValue::from_static("30"));
142/// assert_eq!(
143///     classify_http_status(StatusCode::TOO_MANY_REQUESTS, &headers, &[]),
144///     RetryClass::RetryAfter(Duration::from_secs(30))
145/// );
146/// assert_eq!(
147///     classify_http_status(StatusCode::FORBIDDEN, &HeaderMap::new(), &[]),
148///     RetryClass::Revoked
149/// );
150/// ```
151pub fn classify_http_status(
152    status: StatusCode,
153    headers: &HeaderMap,
154    extra_retry_after_headers: &[&str],
155) -> RetryClass {
156    classify_http_status_with_options(
157        status,
158        headers,
159        RetryHeaderOptions::new(extra_retry_after_headers),
160    )
161}
162
163/// Classifies an upstream HTTP response with configurable retry header parsing.
164///
165/// See [`classify_http_status`] for the status mapping. The options control
166/// which vendor retry headers are checked, whether their first
167/// comma-separated field is used, and the largest delay reported.
168pub fn classify_http_status_with_options(
169    status: StatusCode,
170    headers: &HeaderMap,
171    options: RetryHeaderOptions<'_>,
172) -> RetryClass {
173    match status {
174        StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => RetryClass::Revoked,
175        StatusCode::REQUEST_TIMEOUT | StatusCode::GATEWAY_TIMEOUT => RetryClass::Timeout,
176        StatusCode::TOO_EARLY => RetryClass::Unavailable,
177        StatusCode::TOO_MANY_REQUESTS => retry_after_from_headers_with_options(headers, options)
178            .map_or(RetryClass::RateLimited, RetryClass::RetryAfter),
179        _ if status.is_server_error() => RetryClass::Unavailable,
180        _ => RetryClass::Permanent,
181    }
182}
183
184/// Reads the retry delay from the vendor headers first, then `Retry-After`.
185///
186/// Both `Retry-After` forms are accepted: delay seconds (`Retry-After: 120`)
187/// and an HTTP-date (`Retry-After: Wed, 21 Oct 2026 07:28:00 GMT`). A date in
188/// the past yields [`Duration::ZERO`], meaning retry now. Values that parse as
189/// neither are ignored, and the next candidate header is tried.
190///
191/// `now` is the reference instant for HTTP-date values; pass
192/// [`OffsetDateTime::now_utc`].
193pub fn retry_after_from_headers_at(
194    headers: &HeaderMap,
195    extra_retry_after_headers: &[&str],
196    now: OffsetDateTime,
197) -> Option<Duration> {
198    retry_after_from_headers_with_options_at(
199        headers,
200        RetryHeaderOptions::new(extra_retry_after_headers),
201        now,
202    )
203}
204
205/// Reads a retry delay using configurable vendor header parsing at `now`.
206///
207/// Vendor headers are checked before `Retry-After`. Invalid names and values
208/// are skipped. The standard header always keeps its whole value so HTTP dates
209/// continue to parse when first-field handling is enabled for vendor headers.
210/// A delay above [`RetryHeaderOptions::with_max_retry_after`] is clamped.
211pub fn retry_after_from_headers_with_options_at(
212    headers: &HeaderMap,
213    options: RetryHeaderOptions<'_>,
214    now: OffsetDateTime,
215) -> Option<Duration> {
216    let vendor_delay = options
217        .extra_retry_after_headers
218        .iter()
219        .filter_map(|name| HeaderName::try_from(*name).ok())
220        .filter_map(|name| headers.get(&name).and_then(|value| value.to_str().ok()))
221        .find_map(|value| {
222            let value = if options.first_field {
223                value.split(',').next().unwrap_or(value)
224            } else {
225                value
226            };
227            parse_retry_after(value, now)
228        });
229
230    vendor_delay
231        .or_else(|| {
232            headers
233                .get(RETRY_AFTER)
234                .and_then(|value| value.to_str().ok())
235                .and_then(|value| parse_retry_after(value, now))
236        })
237        .map(|delay| options.cap(delay))
238}
239
240/// Reads the retry delay relative to the current time.
241///
242/// See [`retry_after_from_headers_at`] for the accepted formats.
243pub fn retry_after_from_headers(
244    headers: &HeaderMap,
245    extra_retry_after_headers: &[&str],
246) -> Option<Duration> {
247    retry_after_from_headers_at(
248        headers,
249        extra_retry_after_headers,
250        OffsetDateTime::now_utc(),
251    )
252}
253
254/// Reads a retry delay relative to the current time using `options`.
255///
256/// See [`retry_after_from_headers_with_options_at`] for the accepted formats.
257pub fn retry_after_from_headers_with_options(
258    headers: &HeaderMap,
259    options: RetryHeaderOptions<'_>,
260) -> Option<Duration> {
261    retry_after_from_headers_with_options_at(headers, options, OffsetDateTime::now_utc())
262}
263
264/// Classifies a transport failure that never produced a response.
265///
266/// HTTP clients report timeouts differently, so the caller decides: pass
267/// `is_timeout` from the client's own predicate, for example
268/// `reqwest::Error::is_timeout`. Everything else is treated as a transient
269/// connection problem.
270pub const fn classify_transport_error(is_timeout: bool) -> RetryClass {
271    if is_timeout {
272        RetryClass::Timeout
273    } else {
274        RetryClass::Unavailable
275    }
276}
277
278fn parse_retry_after(value: &str, now: OffsetDateTime) -> Option<Duration> {
279    let value = value.trim();
280    if let Ok(seconds) = value.parse::<u64>() {
281        return Some(Duration::from_secs(seconds));
282    }
283    let deadline = OffsetDateTime::parse(value, &Rfc2822).ok()?;
284    Some((deadline - now).try_into().unwrap_or(Duration::ZERO))
285}
286
287#[cfg(test)]
288mod tests {
289    use axum::http::HeaderValue;
290
291    use super::*;
292
293    const FITBIT_RESET: &str = "fitbit-rate-limit-reset";
294
295    fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
296        let mut map = HeaderMap::new();
297        for (name, value) in pairs {
298            map.insert(
299                HeaderName::try_from(*name).expect("header name should be valid"),
300                HeaderValue::from_str(value).expect("header value should be valid"),
301            );
302        }
303        map
304    }
305
306    fn at(text: &str) -> OffsetDateTime {
307        OffsetDateTime::parse(text, &Rfc2822).expect("reference date should parse")
308    }
309
310    #[test]
311    fn credentials_rejected_by_upstream_are_revoked() {
312        for status in [StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN] {
313            assert_eq!(
314                classify_http_status(status, &HeaderMap::new(), &[]),
315                RetryClass::Revoked
316            );
317        }
318    }
319
320    #[test]
321    fn timeout_statuses_are_classified_as_timeouts() {
322        for status in [StatusCode::REQUEST_TIMEOUT, StatusCode::GATEWAY_TIMEOUT] {
323            assert_eq!(
324                classify_http_status(status, &HeaderMap::new(), &[]),
325                RetryClass::Timeout
326            );
327        }
328    }
329
330    #[test]
331    fn too_early_is_retryable() {
332        let class = classify_http_status(StatusCode::TOO_EARLY, &HeaderMap::new(), &[]);
333        assert_eq!(class, RetryClass::Unavailable);
334        assert!(class.is_retryable());
335    }
336
337    #[test]
338    fn retry_after_is_uncapped_by_default() {
339        assert_eq!(
340            classify_http_status(
341                StatusCode::TOO_MANY_REQUESTS,
342                &headers(&[("retry-after", "86400")]),
343                &[],
344            ),
345            RetryClass::RetryAfter(Duration::from_secs(86_400))
346        );
347    }
348
349    #[test]
350    fn max_retry_after_clamps_longer_delays() {
351        let options = RetryHeaderOptions::default().with_max_retry_after(Duration::from_secs(300));
352        assert_eq!(
353            classify_http_status_with_options(
354                StatusCode::TOO_MANY_REQUESTS,
355                &headers(&[("retry-after", "86400")]),
356                options,
357            ),
358            RetryClass::RetryAfter(Duration::from_secs(300))
359        );
360        assert_eq!(
361            retry_after_from_headers_with_options(&headers(&[("retry-after", "120")]), options),
362            Some(Duration::from_secs(120))
363        );
364    }
365
366    #[test]
367    fn max_retry_after_clamps_http_dates_and_vendor_headers() {
368        let now = at("Wed, 21 Oct 2026 07:28:00 GMT");
369        let cap = Duration::from_secs(60);
370        let date = headers(&[("retry-after", "Thu, 22 Oct 2026 07:28:00 GMT")]);
371        assert_eq!(
372            retry_after_from_headers_with_options_at(
373                &date,
374                RetryHeaderOptions::default().with_max_retry_after(cap),
375                now,
376            ),
377            Some(cap)
378        );
379        let vendor = headers(&[(FITBIT_RESET, "3600")]);
380        assert_eq!(
381            retry_after_from_headers_with_options_at(
382                &vendor,
383                RetryHeaderOptions::new(&[FITBIT_RESET]).with_max_retry_after(cap),
384                now,
385            ),
386            Some(cap)
387        );
388    }
389
390    #[test]
391    fn rate_limit_without_a_usable_header_has_no_delay() {
392        assert_eq!(
393            classify_http_status(StatusCode::TOO_MANY_REQUESTS, &HeaderMap::new(), &[]),
394            RetryClass::RateLimited
395        );
396        assert_eq!(
397            classify_http_status(
398                StatusCode::TOO_MANY_REQUESTS,
399                &headers(&[("retry-after", "soon")]),
400                &[],
401            ),
402            RetryClass::RateLimited
403        );
404    }
405
406    #[test]
407    fn rate_limit_reads_retry_after_seconds() {
408        assert_eq!(
409            classify_http_status(
410                StatusCode::TOO_MANY_REQUESTS,
411                &headers(&[("retry-after", "120")]),
412                &[],
413            ),
414            RetryClass::RetryAfter(Duration::from_secs(120))
415        );
416    }
417
418    #[test]
419    fn retry_after_accepts_an_http_date() {
420        let now = at("Wed, 21 Oct 2026 07:28:00 GMT");
421        let map = headers(&[("retry-after", "Wed, 21 Oct 2026 07:30:00 GMT")]);
422        assert_eq!(
423            retry_after_from_headers_at(&map, &[], now),
424            Some(Duration::from_secs(120))
425        );
426    }
427
428    #[test]
429    fn a_retry_after_date_in_the_past_means_retry_now() {
430        let now = at("Wed, 21 Oct 2026 07:28:00 GMT");
431        let map = headers(&[("retry-after", "Wed, 21 Oct 2026 07:00:00 GMT")]);
432        assert_eq!(
433            retry_after_from_headers_at(&map, &[], now),
434            Some(Duration::ZERO)
435        );
436    }
437
438    #[test]
439    fn vendor_header_wins_over_retry_after() {
440        let map = headers(&[(FITBIT_RESET, "45"), ("retry-after", "120")]);
441        assert_eq!(
442            classify_http_status(StatusCode::TOO_MANY_REQUESTS, &map, &[FITBIT_RESET]),
443            RetryClass::RetryAfter(Duration::from_secs(45))
444        );
445        assert_eq!(
446            classify_http_status(StatusCode::TOO_MANY_REQUESTS, &map, &[]),
447            RetryClass::RetryAfter(Duration::from_secs(120))
448        );
449    }
450
451    #[test]
452    fn an_unparseable_vendor_header_falls_through_to_retry_after() {
453        let map = headers(&[(FITBIT_RESET, "later"), ("retry-after", "9")]);
454        assert_eq!(
455            classify_http_status(StatusCode::TOO_MANY_REQUESTS, &map, &[FITBIT_RESET]),
456            RetryClass::RetryAfter(Duration::from_secs(9))
457        );
458    }
459
460    #[test]
461    fn comma_separated_vendor_header_is_unchanged_by_default() {
462        let map = headers(&[("ratelimit-reset", "30, 3600")]);
463        assert_eq!(
464            classify_http_status(StatusCode::TOO_MANY_REQUESTS, &map, &["ratelimit-reset"],),
465            RetryClass::RateLimited
466        );
467    }
468
469    #[test]
470    fn first_field_option_reads_a_comma_separated_vendor_header() {
471        let map = headers(&[("ratelimit-reset", "30, 3600")]);
472        let options = RetryHeaderOptions::new(&["ratelimit-reset"]).with_first_field();
473        assert_eq!(
474            classify_http_status_with_options(StatusCode::TOO_MANY_REQUESTS, &map, options),
475            RetryClass::RetryAfter(Duration::from_secs(30))
476        );
477    }
478
479    #[test]
480    fn first_field_option_trims_whitespace() {
481        let map = headers(&[("ratelimit-reset", " 30 ,  3600 ")]);
482        let options = RetryHeaderOptions::new(&["ratelimit-reset"]).with_first_field();
483        assert_eq!(
484            retry_after_from_headers_with_options(&map, options),
485            Some(Duration::from_secs(30))
486        );
487    }
488
489    #[test]
490    fn first_field_option_accepts_a_single_value() {
491        let map = headers(&[("ratelimit-reset", "30")]);
492        let options = RetryHeaderOptions::new(&["ratelimit-reset"]).with_first_field();
493        assert_eq!(
494            retry_after_from_headers_with_options(&map, options),
495            Some(Duration::from_secs(30))
496        );
497    }
498
499    #[test]
500    fn first_field_option_does_not_split_retry_after_http_dates() {
501        let now = at("Wed, 21 Oct 2026 07:28:00 GMT");
502        let map = headers(&[("retry-after", "Wed, 21 Oct 2026 07:30:00 GMT")]);
503        let options = RetryHeaderOptions::default().with_first_field();
504        assert_eq!(
505            retry_after_from_headers_with_options_at(&map, options, now),
506            Some(Duration::from_secs(120))
507        );
508    }
509
510    #[test]
511    fn an_invalid_extra_header_name_is_skipped() {
512        let map = headers(&[("retry-after", "5")]);
513        assert_eq!(
514            classify_http_status(StatusCode::TOO_MANY_REQUESTS, &map, &["not a header"]),
515            RetryClass::RetryAfter(Duration::from_secs(5))
516        );
517    }
518
519    #[test]
520    fn other_server_errors_are_unavailable() {
521        for status in [
522            StatusCode::INTERNAL_SERVER_ERROR,
523            StatusCode::BAD_GATEWAY,
524            StatusCode::SERVICE_UNAVAILABLE,
525        ] {
526            assert_eq!(
527                classify_http_status(status, &HeaderMap::new(), &[]),
528                RetryClass::Unavailable
529            );
530        }
531    }
532
533    #[test]
534    fn other_statuses_are_permanent() {
535        for status in [
536            StatusCode::BAD_REQUEST,
537            StatusCode::NOT_FOUND,
538            StatusCode::UNPROCESSABLE_ENTITY,
539            StatusCode::OK,
540        ] {
541            assert_eq!(
542                classify_http_status(status, &HeaderMap::new(), &[]),
543                RetryClass::Permanent
544            );
545        }
546    }
547
548    #[test]
549    fn transport_errors_split_timeouts_from_connection_failures() {
550        assert_eq!(classify_transport_error(true), RetryClass::Timeout);
551        assert_eq!(classify_transport_error(false), RetryClass::Unavailable);
552    }
553
554    #[test]
555    fn retryability_and_delay_are_exposed_per_class() {
556        assert!(RetryClass::RetryAfter(Duration::from_secs(1)).is_retryable());
557        assert!(RetryClass::RateLimited.is_retryable());
558        assert!(RetryClass::Unavailable.is_retryable());
559        assert!(RetryClass::Timeout.is_retryable());
560        assert!(!RetryClass::Revoked.is_retryable());
561        assert!(!RetryClass::Permanent.is_retryable());
562
563        assert_eq!(
564            RetryClass::RetryAfter(Duration::from_secs(3)).retry_after(),
565            Some(Duration::from_secs(3))
566        );
567        assert_eq!(RetryClass::RateLimited.retry_after(), None);
568    }
569}