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)]
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 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 #[must_use]
66 pub const fn with_first_field(mut self) -> Self {
67 self.first_field = true;
68 self
69 }
70
71 #[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 pub const fn is_retryable(self) -> bool {
95 matches!(
96 self,
97 Self::RetryAfter(_) | Self::RateLimited | Self::Unavailable | Self::Timeout
98 )
99 }
100
101 pub const fn retry_after(self) -> Option<Duration> {
103 match self {
104 Self::RetryAfter(delay) => Some(delay),
105 _ => None,
106 }
107 }
108}
109
110pub 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
163pub 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
184pub 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
205pub 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
240pub 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
254pub 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
264pub 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}