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)]
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 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 #[must_use]
70 pub const fn with_first_field(mut self) -> Self {
71 self.first_field = true;
72 self
73 }
74
75 #[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 #[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 pub const fn is_retryable(self) -> bool {
112 matches!(
113 self,
114 Self::RetryAfter(_) | Self::RateLimited | Self::Unavailable | Self::Timeout
115 )
116 }
117
118 pub const fn retry_after(self) -> Option<Duration> {
120 match self {
121 Self::RetryAfter(delay) => Some(delay),
122 _ => None,
123 }
124 }
125}
126
127pub 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
180pub 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
195pub 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
248pub 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
269pub 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
304pub 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
318pub 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
328pub 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}