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(
171 status: StatusCode,
172 headers: &HeaderMap,
173 extra_retry_after_headers: &[&str],
174) -> RetryClass {
175 classify_http_status_with_options(
176 status,
177 headers,
178 RetryHeaderOptions::new(extra_retry_after_headers),
179 )
180}
181
182pub fn classify_http_status_with_options(
192 status: StatusCode,
193 headers: &HeaderMap,
194 options: RetryHeaderOptions<'_>,
195) -> RetryClass {
196 classify_http_status_with_options_at(status, headers, options, OffsetDateTime::now_utc())
197}
198
199pub fn classify_http_status_with_options_at(
207 status: StatusCode,
208 headers: &HeaderMap,
209 options: RetryHeaderOptions<'_>,
210 now: OffsetDateTime,
211) -> RetryClass {
212 match status {
213 StatusCode::FORBIDDEN if options.forbidden_rate_limit => {
214 classify_forbidden_rate_limit(headers, options, now)
215 }
216 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => RetryClass::Revoked,
217 StatusCode::REQUEST_TIMEOUT => RetryClass::Timeout,
218 StatusCode::TOO_EARLY => RetryClass::Unavailable,
219 StatusCode::TOO_MANY_REQUESTS => {
220 retry_after_from_headers_with_options_at(headers, options, now)
221 .map_or(RetryClass::RateLimited, RetryClass::RetryAfter)
222 }
223 _ if status.is_server_error() => {
224 let fallback = if status == StatusCode::GATEWAY_TIMEOUT {
225 RetryClass::Timeout
226 } else {
227 RetryClass::Unavailable
228 };
229 retry_after_from_headers_with_options_at(headers, options, now)
230 .map_or(fallback, RetryClass::RetryAfter)
231 }
232 _ => RetryClass::Permanent,
233 }
234}
235
236fn classify_forbidden_rate_limit(
237 headers: &HeaderMap,
238 options: RetryHeaderOptions<'_>,
239 now: OffsetDateTime,
240) -> RetryClass {
241 if let Some(delay) = retry_after_from_headers_with_options_at(headers, options, now) {
242 return RetryClass::RetryAfter(delay);
243 }
244 let remaining = headers
245 .get("x-ratelimit-remaining")
246 .and_then(|value| value.to_str().ok());
247 if remaining.map(str::trim) != Some("0") {
248 return RetryClass::Revoked;
249 }
250 let reset = headers
251 .get("x-ratelimit-reset")
252 .and_then(|value| value.to_str().ok())
253 .and_then(|value| value.trim().parse::<i64>().ok())
254 .and_then(|epoch| OffsetDateTime::from_unix_timestamp(epoch).ok());
255 reset.map_or(RetryClass::RateLimited, |deadline| {
256 let delay = (deadline - now).try_into().unwrap_or(Duration::ZERO);
257 RetryClass::RetryAfter(options.cap(delay))
258 })
259}
260
261pub fn retry_after_from_headers_at(
271 headers: &HeaderMap,
272 extra_retry_after_headers: &[&str],
273 now: OffsetDateTime,
274) -> Option<Duration> {
275 retry_after_from_headers_with_options_at(
276 headers,
277 RetryHeaderOptions::new(extra_retry_after_headers),
278 now,
279 )
280}
281
282pub fn retry_after_from_headers_with_options_at(
289 headers: &HeaderMap,
290 options: RetryHeaderOptions<'_>,
291 now: OffsetDateTime,
292) -> Option<Duration> {
293 let vendor_delay = options
294 .extra_retry_after_headers
295 .iter()
296 .filter_map(|name| HeaderName::try_from(*name).ok())
297 .filter_map(|name| headers.get(&name).and_then(|value| value.to_str().ok()))
298 .find_map(|value| {
299 let value = if options.first_field {
300 value.split(',').next().unwrap_or(value)
301 } else {
302 value
303 };
304 parse_retry_after(value, now)
305 });
306
307 vendor_delay
308 .or_else(|| {
309 headers
310 .get(RETRY_AFTER)
311 .and_then(|value| value.to_str().ok())
312 .and_then(|value| parse_retry_after(value, now))
313 })
314 .map(|delay| options.cap(delay))
315}
316
317pub fn retry_after_from_headers(
321 headers: &HeaderMap,
322 extra_retry_after_headers: &[&str],
323) -> Option<Duration> {
324 retry_after_from_headers_at(
325 headers,
326 extra_retry_after_headers,
327 OffsetDateTime::now_utc(),
328 )
329}
330
331pub fn retry_after_from_headers_with_options(
335 headers: &HeaderMap,
336 options: RetryHeaderOptions<'_>,
337) -> Option<Duration> {
338 retry_after_from_headers_with_options_at(headers, options, OffsetDateTime::now_utc())
339}
340
341pub const fn classify_transport_error(is_timeout: bool) -> RetryClass {
348 if is_timeout {
349 RetryClass::Timeout
350 } else {
351 RetryClass::Unavailable
352 }
353}
354
355fn parse_retry_after(value: &str, now: OffsetDateTime) -> Option<Duration> {
356 let value = value.trim();
357 if let Ok(seconds) = value.parse::<u64>() {
358 return Some(Duration::from_secs(seconds));
359 }
360 let deadline = OffsetDateTime::parse(value, &Rfc2822).ok()?;
361 Some((deadline - now).try_into().unwrap_or(Duration::ZERO))
362}
363
364#[cfg(test)]
365mod tests {
366 use axum::http::HeaderValue;
367
368 use super::*;
369
370 const FITBIT_RESET: &str = "fitbit-rate-limit-reset";
371
372 fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
373 let mut map = HeaderMap::new();
374 for (name, value) in pairs {
375 map.insert(
376 HeaderName::try_from(*name).expect("header name should be valid"),
377 HeaderValue::from_str(value).expect("header value should be valid"),
378 );
379 }
380 map
381 }
382
383 fn at(text: &str) -> OffsetDateTime {
384 OffsetDateTime::parse(text, &Rfc2822).expect("reference date should parse")
385 }
386
387 #[test]
388 fn credentials_rejected_by_upstream_are_revoked() {
389 for status in [StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN] {
390 assert_eq!(
391 classify_http_status(status, &HeaderMap::new(), &[]),
392 RetryClass::Revoked
393 );
394 }
395 }
396
397 #[test]
398 fn forbidden_rate_limit_reads_retry_after_seconds() {
399 let options = RetryHeaderOptions::default().with_forbidden_rate_limit();
400 for seconds in [0, 120, 86_400] {
401 assert_eq!(
402 classify_http_status_with_options(
403 StatusCode::FORBIDDEN,
404 &headers(&[("retry-after", &seconds.to_string())]),
405 options,
406 ),
407 RetryClass::RetryAfter(Duration::from_secs(seconds))
408 );
409 }
410 }
411
412 #[test]
413 fn forbidden_rate_limit_reads_retry_after_http_dates() {
414 let now = at("Wed, 21 Oct 2026 07:28:00 GMT");
415 let options = RetryHeaderOptions::default().with_forbidden_rate_limit();
416 let cases = [
417 ("Wed, 21 Oct 2026 07:30:00 GMT", Duration::from_secs(120)),
418 ("Wed, 21 Oct 2026 07:00:00 GMT", Duration::ZERO),
419 ];
420 for (date, delay) in cases {
421 assert_eq!(
422 classify_http_status_with_options_at(
423 StatusCode::FORBIDDEN,
424 &headers(&[("retry-after", date)]),
425 options,
426 now,
427 ),
428 RetryClass::RetryAfter(delay)
429 );
430 }
431 }
432
433 #[test]
434 fn forbidden_rate_limit_reads_reset_epochs_and_saturates_past_resets() {
435 let now = OffsetDateTime::from_unix_timestamp(1_000).expect("reference epoch is valid");
436 let options = RetryHeaderOptions::default().with_forbidden_rate_limit();
437 for (reset, delay) in [
438 ("1120", Duration::from_secs(120)),
439 ("1000", Duration::ZERO),
440 ("880", Duration::ZERO),
441 ("-1", Duration::ZERO),
442 ] {
443 assert_eq!(
444 classify_http_status_with_options_at(
445 StatusCode::FORBIDDEN,
446 &headers(&[("x-ratelimit-remaining", "0"), ("x-ratelimit-reset", reset)]),
447 options,
448 now,
449 ),
450 RetryClass::RetryAfter(delay),
451 "reset: {reset}"
452 );
453 }
454 }
455
456 #[test]
457 fn forbidden_rate_limit_without_a_usable_reset_has_no_delay() {
458 let now = OffsetDateTime::from_unix_timestamp(1_000).expect("reference epoch is valid");
459 let options = RetryHeaderOptions::default().with_forbidden_rate_limit();
460 for reset in [
461 None,
462 Some("later"),
463 Some("Wed, 21 Oct 2026 07:30:00 GMT"),
464 Some("1120.5"),
465 Some("9223372036854775807"),
466 Some("18446744073709551616"),
467 ] {
468 let mut map = headers(&[("x-ratelimit-remaining", "0"), ("retry-after", "soon")]);
469 if let Some(reset) = reset {
470 map.insert(
471 "x-ratelimit-reset",
472 HeaderValue::from_str(reset).expect("reset should be a valid header value"),
473 );
474 }
475 assert_eq!(
476 classify_http_status_with_options_at(StatusCode::FORBIDDEN, &map, options, now),
477 RetryClass::RateLimited,
478 "reset: {reset:?}"
479 );
480 }
481 }
482
483 #[test]
484 fn forbidden_without_rate_limit_evidence_stays_revoked() {
485 let now = OffsetDateTime::from_unix_timestamp(1_000).expect("reference epoch is valid");
486 let options = RetryHeaderOptions::default().with_forbidden_rate_limit();
487 let cases = [
488 HeaderMap::new(),
489 headers(&[("retry-after", "soon")]),
490 headers(&[("x-ratelimit-reset", "1120")]),
491 headers(&[
492 ("x-ratelimit-remaining", "1"),
493 ("x-ratelimit-reset", "1120"),
494 ]),
495 headers(&[
496 ("x-ratelimit-remaining", "invalid"),
497 ("x-ratelimit-reset", "1120"),
498 ]),
499 ];
500 for map in cases {
501 assert_eq!(
502 classify_http_status_with_options_at(StatusCode::FORBIDDEN, &map, options, now),
503 RetryClass::Revoked,
504 "headers: {map:?}"
505 );
506 }
507 }
508
509 #[test]
510 fn unauthorized_stays_revoked_with_forbidden_rate_limit_enabled() {
511 let options = RetryHeaderOptions::new(&[FITBIT_RESET]).with_forbidden_rate_limit();
512 let map = headers(&[
513 ("retry-after", "120"),
514 (FITBIT_RESET, "45"),
515 ("x-ratelimit-remaining", "0"),
516 ("x-ratelimit-reset", "1120"),
517 ]);
518 assert_eq!(
519 classify_http_status_with_options(StatusCode::UNAUTHORIZED, &map, options),
520 RetryClass::Revoked
521 );
522 }
523
524 #[test]
525 fn forbidden_rate_limit_headers_are_ignored_by_default() {
526 let now = OffsetDateTime::from_unix_timestamp(1_000).expect("reference epoch is valid");
527 let cases = [
528 headers(&[("retry-after", "120")]),
529 headers(&[("retry-after", "Wed, 21 Oct 2026 07:30:00 GMT")]),
530 headers(&[(FITBIT_RESET, "45")]),
531 headers(&[("ratelimit-reset", "30, 3600")]),
532 headers(&[
533 ("x-ratelimit-remaining", "0"),
534 ("x-ratelimit-reset", "1120"),
535 ]),
536 headers(&[("x-ratelimit-remaining", "0"), ("x-ratelimit-reset", "880")]),
537 headers(&[("x-ratelimit-remaining", "0")]),
538 headers(&[
539 ("x-ratelimit-remaining", "0"),
540 ("x-ratelimit-reset", "later"),
541 ]),
542 headers(&[
543 ("x-ratelimit-remaining", "1"),
544 ("x-ratelimit-reset", "1120"),
545 ]),
546 headers(&[
547 ("retry-after", "120"),
548 (FITBIT_RESET, "45"),
549 ("x-ratelimit-remaining", "0"),
550 ("x-ratelimit-reset", "1120"),
551 ]),
552 HeaderMap::new(),
553 ];
554 for options in [
555 RetryHeaderOptions::default(),
556 RetryHeaderOptions::new(&[FITBIT_RESET, "ratelimit-reset"]).with_first_field(),
557 ] {
558 for map in &cases {
559 assert_eq!(
560 classify_http_status_with_options_at(StatusCode::FORBIDDEN, map, options, now),
561 RetryClass::Revoked,
562 "headers: {map:?}"
563 );
564 }
565 }
566 }
567
568 #[test]
569 fn forbidden_rate_limit_delays_are_capped_for_every_header_source() {
570 let now = OffsetDateTime::from_unix_timestamp(1_000).expect("reference epoch is valid");
571 let cases = [
572 headers(&[("retry-after", "3600")]),
573 headers(&[("retry-after", "Thu, 01 Jan 1970 01:16:40 GMT")]),
574 headers(&[(FITBIT_RESET, "3600")]),
575 headers(&[
576 ("x-ratelimit-remaining", "0"),
577 ("x-ratelimit-reset", "4600"),
578 ]),
579 ];
580 for cap in [Duration::ZERO, Duration::from_secs(60)] {
581 let options = RetryHeaderOptions::new(&[FITBIT_RESET])
582 .with_forbidden_rate_limit()
583 .with_max_retry_after(cap);
584 for map in &cases {
585 assert_eq!(
586 classify_http_status_with_options_at(StatusCode::FORBIDDEN, map, options, now),
587 RetryClass::RetryAfter(cap),
588 "headers: {map:?}"
589 );
590 }
591 }
592 }
593
594 #[test]
595 fn forbidden_rate_limit_keeps_vendor_parsing_and_header_precedence() {
596 let options = RetryHeaderOptions::new(&[FITBIT_RESET]).with_forbidden_rate_limit();
597 for (vendor, delay) in [("45", 45), ("later", 120)] {
598 let map = headers(&[
599 (FITBIT_RESET, vendor),
600 ("retry-after", "120"),
601 ("x-ratelimit-remaining", "0"),
602 ("x-ratelimit-reset", "0"),
603 ]);
604 assert_eq!(
605 classify_http_status_with_options(StatusCode::FORBIDDEN, &map, options),
606 RetryClass::RetryAfter(Duration::from_secs(delay))
607 );
608 }
609 let map = headers(&[("ratelimit-reset", "30, 3600")]);
610 let options = RetryHeaderOptions::new(&["ratelimit-reset"]).with_forbidden_rate_limit();
611 assert_eq!(
612 classify_http_status_with_options(StatusCode::FORBIDDEN, &map, options),
613 RetryClass::Revoked
614 );
615 assert_eq!(
616 classify_http_status_with_options(
617 StatusCode::FORBIDDEN,
618 &map,
619 options.with_first_field()
620 ),
621 RetryClass::RetryAfter(Duration::from_secs(30))
622 );
623 }
624
625 #[test]
626 fn reset_epoch_handling_does_not_change_other_statuses_or_vendor_parsing() {
627 let now = OffsetDateTime::from_unix_timestamp(1_000).expect("reference epoch is valid");
628 let map = headers(&[
629 ("x-ratelimit-remaining", "0"),
630 ("x-ratelimit-reset", "1120"),
631 ]);
632 let options = RetryHeaderOptions::default().with_forbidden_rate_limit();
633 assert_eq!(
634 classify_http_status_with_options_at(StatusCode::TOO_MANY_REQUESTS, &map, options, now),
635 RetryClass::RateLimited
636 );
637 let options = RetryHeaderOptions::new(&["x-ratelimit-reset"]).with_forbidden_rate_limit();
638 for status in [StatusCode::TOO_MANY_REQUESTS, StatusCode::FORBIDDEN] {
639 assert_eq!(
640 classify_http_status_with_options_at(status, &map, options, now),
641 RetryClass::RetryAfter(Duration::from_secs(1_120))
642 );
643 }
644 }
645
646 #[test]
647 fn timeout_statuses_are_classified_as_timeouts() {
648 for status in [StatusCode::REQUEST_TIMEOUT, StatusCode::GATEWAY_TIMEOUT] {
649 assert_eq!(
650 classify_http_status(status, &HeaderMap::new(), &[]),
651 RetryClass::Timeout
652 );
653 }
654 }
655
656 #[test]
657 fn server_errors_honor_retry_after_seconds() {
658 for code in 500..=599 {
659 let status = StatusCode::from_u16(code).expect("server status is valid");
660 assert_eq!(
661 classify_http_status(status, &headers(&[("retry-after", "7")]), &[]),
662 RetryClass::RetryAfter(Duration::from_secs(7)),
663 "status {code}"
664 );
665 }
666 }
667
668 #[test]
669 fn service_unavailable_honors_retry_after_http_date() {
670 let now = at("Wed, 21 Oct 2026 07:28:00 GMT");
671 assert_eq!(
672 classify_http_status_with_options_at(
673 StatusCode::SERVICE_UNAVAILABLE,
674 &headers(&[("retry-after", "Wed, 21 Oct 2026 07:30:00 GMT")]),
675 RetryHeaderOptions::default(),
676 now,
677 ),
678 RetryClass::RetryAfter(Duration::from_secs(120))
679 );
680 }
681
682 #[test]
683 fn server_error_retry_after_is_capped() {
684 let options = RetryHeaderOptions::default().with_max_retry_after(Duration::from_secs(30));
685 for value in ["86400", "Thu, 22 Oct 2026 07:28:00 GMT"] {
686 assert_eq!(
687 classify_http_status_with_options_at(
688 StatusCode::SERVICE_UNAVAILABLE,
689 &headers(&[("retry-after", value)]),
690 options,
691 at("Wed, 21 Oct 2026 07:28:00 GMT"),
692 ),
693 RetryClass::RetryAfter(Duration::from_secs(30))
694 );
695 }
696 }
697
698 #[test]
699 fn server_errors_without_a_usable_retry_after_keep_their_class() {
700 for code in 500..=599 {
701 let status = StatusCode::from_u16(code).expect("server status is valid");
702 let expected = if status == StatusCode::GATEWAY_TIMEOUT {
703 RetryClass::Timeout
704 } else {
705 RetryClass::Unavailable
706 };
707 for map in [HeaderMap::new(), headers(&[("retry-after", "invalid")])] {
708 assert_eq!(
709 classify_http_status(status, &map, &[]),
710 expected,
711 "status {code}"
712 );
713 }
714 }
715 }
716
717 #[test]
718 fn too_early_is_retryable() {
719 let class = classify_http_status(StatusCode::TOO_EARLY, &HeaderMap::new(), &[]);
720 assert_eq!(class, RetryClass::Unavailable);
721 assert!(class.is_retryable());
722 }
723
724 #[test]
725 fn retry_after_is_uncapped_by_default() {
726 assert_eq!(
727 classify_http_status(
728 StatusCode::TOO_MANY_REQUESTS,
729 &headers(&[("retry-after", "86400")]),
730 &[],
731 ),
732 RetryClass::RetryAfter(Duration::from_secs(86_400))
733 );
734 }
735
736 #[test]
737 fn max_retry_after_clamps_longer_delays() {
738 let options = RetryHeaderOptions::default().with_max_retry_after(Duration::from_secs(300));
739 assert_eq!(
740 classify_http_status_with_options(
741 StatusCode::TOO_MANY_REQUESTS,
742 &headers(&[("retry-after", "86400")]),
743 options,
744 ),
745 RetryClass::RetryAfter(Duration::from_secs(300))
746 );
747 assert_eq!(
748 retry_after_from_headers_with_options(&headers(&[("retry-after", "120")]), options),
749 Some(Duration::from_secs(120))
750 );
751 }
752
753 #[test]
754 fn max_retry_after_clamps_http_dates_and_vendor_headers() {
755 let now = at("Wed, 21 Oct 2026 07:28:00 GMT");
756 let cap = Duration::from_secs(60);
757 let date = headers(&[("retry-after", "Thu, 22 Oct 2026 07:28:00 GMT")]);
758 assert_eq!(
759 retry_after_from_headers_with_options_at(
760 &date,
761 RetryHeaderOptions::default().with_max_retry_after(cap),
762 now,
763 ),
764 Some(cap)
765 );
766 let vendor = headers(&[(FITBIT_RESET, "3600")]);
767 assert_eq!(
768 retry_after_from_headers_with_options_at(
769 &vendor,
770 RetryHeaderOptions::new(&[FITBIT_RESET]).with_max_retry_after(cap),
771 now,
772 ),
773 Some(cap)
774 );
775 }
776
777 #[test]
778 fn rate_limit_without_a_usable_header_has_no_delay() {
779 assert_eq!(
780 classify_http_status(StatusCode::TOO_MANY_REQUESTS, &HeaderMap::new(), &[]),
781 RetryClass::RateLimited
782 );
783 assert_eq!(
784 classify_http_status(
785 StatusCode::TOO_MANY_REQUESTS,
786 &headers(&[("retry-after", "soon")]),
787 &[],
788 ),
789 RetryClass::RateLimited
790 );
791 }
792
793 #[test]
794 fn rate_limit_reads_retry_after_seconds() {
795 assert_eq!(
796 classify_http_status(
797 StatusCode::TOO_MANY_REQUESTS,
798 &headers(&[("retry-after", "120")]),
799 &[],
800 ),
801 RetryClass::RetryAfter(Duration::from_secs(120))
802 );
803 }
804
805 #[test]
806 fn retry_after_accepts_an_http_date() {
807 let now = at("Wed, 21 Oct 2026 07:28:00 GMT");
808 let map = headers(&[("retry-after", "Wed, 21 Oct 2026 07:30:00 GMT")]);
809 assert_eq!(
810 retry_after_from_headers_at(&map, &[], now),
811 Some(Duration::from_secs(120))
812 );
813 }
814
815 #[test]
816 fn a_retry_after_date_in_the_past_means_retry_now() {
817 let now = at("Wed, 21 Oct 2026 07:28:00 GMT");
818 let map = headers(&[("retry-after", "Wed, 21 Oct 2026 07:00:00 GMT")]);
819 assert_eq!(
820 retry_after_from_headers_at(&map, &[], now),
821 Some(Duration::ZERO)
822 );
823 }
824
825 #[test]
826 fn vendor_header_wins_over_retry_after() {
827 let map = headers(&[(FITBIT_RESET, "45"), ("retry-after", "120")]);
828 assert_eq!(
829 classify_http_status(StatusCode::TOO_MANY_REQUESTS, &map, &[FITBIT_RESET]),
830 RetryClass::RetryAfter(Duration::from_secs(45))
831 );
832 assert_eq!(
833 classify_http_status(StatusCode::TOO_MANY_REQUESTS, &map, &[]),
834 RetryClass::RetryAfter(Duration::from_secs(120))
835 );
836 }
837
838 #[test]
839 fn an_unparseable_vendor_header_falls_through_to_retry_after() {
840 let map = headers(&[(FITBIT_RESET, "later"), ("retry-after", "9")]);
841 assert_eq!(
842 classify_http_status(StatusCode::TOO_MANY_REQUESTS, &map, &[FITBIT_RESET]),
843 RetryClass::RetryAfter(Duration::from_secs(9))
844 );
845 }
846
847 #[test]
848 fn comma_separated_vendor_header_is_unchanged_by_default() {
849 let map = headers(&[("ratelimit-reset", "30, 3600")]);
850 assert_eq!(
851 classify_http_status(StatusCode::TOO_MANY_REQUESTS, &map, &["ratelimit-reset"],),
852 RetryClass::RateLimited
853 );
854 }
855
856 #[test]
857 fn first_field_option_reads_a_comma_separated_vendor_header() {
858 let map = headers(&[("ratelimit-reset", "30, 3600")]);
859 let options = RetryHeaderOptions::new(&["ratelimit-reset"]).with_first_field();
860 assert_eq!(
861 classify_http_status_with_options(StatusCode::TOO_MANY_REQUESTS, &map, options),
862 RetryClass::RetryAfter(Duration::from_secs(30))
863 );
864 }
865
866 #[test]
867 fn first_field_option_trims_whitespace() {
868 let map = headers(&[("ratelimit-reset", " 30 , 3600 ")]);
869 let options = RetryHeaderOptions::new(&["ratelimit-reset"]).with_first_field();
870 assert_eq!(
871 retry_after_from_headers_with_options(&map, options),
872 Some(Duration::from_secs(30))
873 );
874 }
875
876 #[test]
877 fn first_field_option_accepts_a_single_value() {
878 let map = headers(&[("ratelimit-reset", "30")]);
879 let options = RetryHeaderOptions::new(&["ratelimit-reset"]).with_first_field();
880 assert_eq!(
881 retry_after_from_headers_with_options(&map, options),
882 Some(Duration::from_secs(30))
883 );
884 }
885
886 #[test]
887 fn first_field_option_does_not_split_retry_after_http_dates() {
888 let now = at("Wed, 21 Oct 2026 07:28:00 GMT");
889 let map = headers(&[("retry-after", "Wed, 21 Oct 2026 07:30:00 GMT")]);
890 let options = RetryHeaderOptions::default().with_first_field();
891 assert_eq!(
892 retry_after_from_headers_with_options_at(&map, options, now),
893 Some(Duration::from_secs(120))
894 );
895 }
896
897 #[test]
898 fn an_invalid_extra_header_name_is_skipped() {
899 let map = headers(&[("retry-after", "5")]);
900 assert_eq!(
901 classify_http_status(StatusCode::TOO_MANY_REQUESTS, &map, &["not a header"]),
902 RetryClass::RetryAfter(Duration::from_secs(5))
903 );
904 }
905
906 #[test]
907 fn other_server_errors_are_unavailable() {
908 for status in [
909 StatusCode::INTERNAL_SERVER_ERROR,
910 StatusCode::BAD_GATEWAY,
911 StatusCode::SERVICE_UNAVAILABLE,
912 ] {
913 assert_eq!(
914 classify_http_status(status, &HeaderMap::new(), &[]),
915 RetryClass::Unavailable
916 );
917 }
918 }
919
920 #[test]
921 fn other_statuses_are_permanent() {
922 for status in [
923 StatusCode::BAD_REQUEST,
924 StatusCode::NOT_FOUND,
925 StatusCode::UNPROCESSABLE_ENTITY,
926 StatusCode::OK,
927 ] {
928 assert_eq!(
929 classify_http_status(status, &HeaderMap::new(), &[]),
930 RetryClass::Permanent
931 );
932 }
933 }
934
935 #[test]
936 fn transport_errors_split_timeouts_from_connection_failures() {
937 assert_eq!(classify_transport_error(true), RetryClass::Timeout);
938 assert_eq!(classify_transport_error(false), RetryClass::Unavailable);
939 }
940
941 #[test]
942 fn retryability_and_delay_are_exposed_per_class() {
943 assert!(RetryClass::RetryAfter(Duration::from_secs(1)).is_retryable());
944 assert!(RetryClass::RateLimited.is_retryable());
945 assert!(RetryClass::Unavailable.is_retryable());
946 assert!(RetryClass::Timeout.is_retryable());
947 assert!(!RetryClass::Revoked.is_retryable());
948 assert!(!RetryClass::Permanent.is_retryable());
949
950 assert_eq!(
951 RetryClass::RetryAfter(Duration::from_secs(3)).retry_after(),
952 Some(Duration::from_secs(3))
953 );
954 assert_eq!(RetryClass::RateLimited.retry_after(), None);
955 }
956}