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