1use std::time::Duration;
9
10use axum::http::{HeaderMap, HeaderName, StatusCode, header::RETRY_AFTER};
11use time::{OffsetDateTime, format_description::well_known::Rfc2822};
12
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum RetryClass {
16 RetryAfter(Duration),
20 RateLimited,
24 Unavailable,
26 Timeout,
28 Revoked,
30 Permanent,
32}
33
34#[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 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 #[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 pub const fn is_retryable(self) -> bool {
72 matches!(
73 self,
74 Self::RetryAfter(_) | Self::RateLimited | Self::Unavailable | Self::Timeout
75 )
76 }
77
78 pub const fn retry_after(self) -> Option<Duration> {
80 match self {
81 Self::RetryAfter(delay) => Some(delay),
82 _ => None,
83 }
84 }
85}
86
87pub 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
137pub 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
157pub 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
178pub 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
210pub 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
224pub 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
234pub 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}