use std::time::{Duration, SystemTime};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub base_delay: Duration,
pub max_delay: Duration,
pub multiplier: f64,
}
pub const DEFAULT_RETRY_POLICY: RetryPolicy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_millis(500),
max_delay: Duration::from_secs(8),
multiplier: 2.0,
};
pub const NO_RETRY: RetryPolicy = RetryPolicy {
max_attempts: 1,
base_delay: Duration::from_millis(500),
max_delay: Duration::from_secs(8),
multiplier: 2.0,
};
pub(crate) fn is_retryable_status(status_code: u16, headers: &[(String, String)]) -> bool {
if matches!(status_code, 502..=504) {
return true;
}
if status_code == 429 {
return parse_retry_after(headers, None).is_some();
}
false
}
pub(crate) fn backoff_delay(attempt: u32, policy: &RetryPolicy) -> f64 {
let exponent = attempt.saturating_sub(1);
let base = policy.base_delay.as_secs_f64();
let max = policy.max_delay.as_secs_f64();
let capped = max.min(base * policy.multiplier.powi(exponent as i32));
if capped <= 0.0 {
return 0.0;
}
rand::random_range(0.0..=capped)
}
pub(crate) fn parse_retry_after(
headers: &[(String, String)],
now: Option<SystemTime>,
) -> Option<f64> {
let raw = header_value(headers, "Retry-After")?.trim();
if raw.is_empty() {
return None;
}
if let Ok(seconds) = raw.parse::<f64>() {
if !seconds.is_finite() || seconds < 0.0 {
return None;
}
return Some(seconds);
}
let target = httpdate::parse_http_date(raw).ok()?;
let now = now.unwrap_or_else(SystemTime::now);
match target.duration_since(now) {
Ok(remaining) => Some(remaining.as_secs_f64()),
Err(_) => Some(0.0),
}
}
pub(crate) fn effective_delay(
attempt: u32,
policy: &RetryPolicy,
headers: &[(String, String)],
) -> f64 {
let mut delay = backoff_delay(attempt, policy);
if let Some(hint) = parse_retry_after(headers, None) {
delay = delay.max(hint);
}
policy.max_delay.as_secs_f64().min(delay)
}
fn header_value<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn hdrs(value: &str) -> Vec<(String, String)> {
vec![("Retry-After".to_string(), value.to_string())]
}
#[test]
fn retryable_statuses() {
assert!(is_retryable_status(502, &[]));
assert!(is_retryable_status(503, &[]));
assert!(is_retryable_status(504, &[]));
assert!(!is_retryable_status(500, &[]));
assert!(!is_retryable_status(429, &[]));
assert!(is_retryable_status(429, &hdrs("2")));
assert!(!is_retryable_status(429, &hdrs("-1")));
}
#[test]
fn parse_retry_after_delta_seconds() {
assert_eq!(parse_retry_after(&hdrs("2"), None), Some(2.0));
assert_eq!(parse_retry_after(&hdrs(" 1.5 "), None), Some(1.5));
assert_eq!(parse_retry_after(&hdrs("-3"), None), None);
assert_eq!(parse_retry_after(&hdrs("inf"), None), None);
assert_eq!(parse_retry_after(&hdrs("nan"), None), None);
assert_eq!(parse_retry_after(&hdrs(""), None), None);
assert_eq!(parse_retry_after(&[], None), None);
}
#[test]
fn parse_retry_after_http_date() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(784_111_777);
let value = "Sun, 06 Nov 1994 08:50:00 GMT";
let parsed = parse_retry_after(&hdrs(value), Some(now)).unwrap();
assert!((parsed - 23.0).abs() < 1.0, "parsed={parsed}");
let past = "Sun, 06 Nov 1994 08:00:00 GMT";
assert_eq!(parse_retry_after(&hdrs(past), Some(now)), Some(0.0));
assert_eq!(parse_retry_after(&hdrs("tomorrow"), Some(now)), None);
}
#[test]
fn backoff_within_bounds() {
for attempt in 1..6 {
let delay = backoff_delay(attempt, &DEFAULT_RETRY_POLICY);
assert!(delay >= 0.0);
assert!(delay <= DEFAULT_RETRY_POLICY.max_delay.as_secs_f64());
}
}
#[test]
fn effective_delay_prefers_hint_but_caps() {
let delay = effective_delay(1, &DEFAULT_RETRY_POLICY, &hdrs("100"));
assert_eq!(delay, DEFAULT_RETRY_POLICY.max_delay.as_secs_f64());
let delay = effective_delay(1, &DEFAULT_RETRY_POLICY, &hdrs("3"));
assert!(delay >= 3.0);
}
use proptest::prelude::*;
proptest! {
#[test]
fn backoff_stays_within_bounds(
attempt in 0u32..32,
base in 0.0f64..5.0,
multiplier in 1.0f64..4.0,
max in 0.0f64..30.0,
) {
let policy = RetryPolicy { max_attempts: 3, base_delay: Duration::from_secs_f64(base), max_delay: Duration::from_secs_f64(max), multiplier };
let delay = backoff_delay(attempt, &policy);
prop_assert!(delay >= 0.0);
prop_assert!(delay <= policy.max_delay.as_secs_f64());
}
#[test]
fn effective_delay_caps_and_honors_hint(
attempt in 1u32..10,
hint in 0.0f64..100.0,
max in 0.0f64..50.0,
) {
let policy = RetryPolicy { max_attempts: 3, base_delay: Duration::from_millis(500), max_delay: Duration::from_secs_f64(max), multiplier: 2.0 };
let max_secs = policy.max_delay.as_secs_f64();
let headers = hdrs(&format!("{hint}"));
let delay = effective_delay(attempt, &policy, &headers);
prop_assert!(delay <= max_secs);
prop_assert!(delay >= hint.min(max_secs));
}
}
}