use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, PartialEq)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub initial_backoff: Duration,
pub max_backoff: Duration,
pub multiplier: f64,
pub jitter: f64,
pub retry_statuses: Vec<u16>,
pub honor_retry_after: bool,
pub max_retry_after: Duration,
pub defer_over_cap: bool,
pub max_transport_attempts: Option<u32>,
pub max_status_attempts: Option<u32>,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_attempts: 3,
initial_backoff: Duration::from_millis(500),
max_backoff: Duration::from_secs(8),
multiplier: 2.0,
jitter: 0.2,
retry_statuses: vec![408, 425, 429, 500, 502, 503, 504],
honor_retry_after: true,
max_retry_after: Duration::from_secs(30),
defer_over_cap: false,
max_transport_attempts: None,
max_status_attempts: None,
}
}
}
impl RetryPolicy {
pub fn defers(&self, retry_after: Option<Duration>) -> bool {
self.defer_over_cap
&& self.honor_retry_after
&& retry_after.is_some_and(|ra| ra > self.max_retry_after)
}
pub fn none() -> Self {
Self {
max_attempts: 1,
..Self::default()
}
}
pub fn is_retryable_status(&self, status: u16) -> bool {
self.retry_statuses.contains(&status)
}
pub fn separate_budgets(&self) -> bool {
self.max_transport_attempts.is_some() || self.max_status_attempts.is_some()
}
pub fn backoff(&self, retry: u32) -> Duration {
let exp = retry.saturating_sub(1).min(32) as i32;
let secs = self.initial_backoff.as_secs_f64() * self.multiplier.powi(exp);
let capped = secs.min(self.max_backoff.as_secs_f64());
let shaved = if self.jitter > 0.0 {
capped * (1.0 - self.jitter.clamp(0.0, 1.0) * random_unit())
} else {
capped
};
Duration::try_from_secs_f64(shaved.max(0.0)).unwrap_or(self.max_backoff)
}
pub fn delay(&self, retry: u32, retry_after: Option<Duration>) -> Duration {
let base = self.backoff(retry);
match (self.honor_retry_after, retry_after) {
(true, Some(ra)) => base.max(ra.min(self.max_retry_after)),
_ => base,
}
}
}
pub(crate) struct AttemptBudgets {
transport: u32,
status: u32,
}
impl AttemptBudgets {
pub(crate) fn new() -> Self {
Self {
transport: 0,
status: 0,
}
}
fn may_retry(
&self,
policy: &RetryPolicy,
attempt: u32,
failures: u32,
budget: Option<u32>,
) -> Option<u32> {
if attempt >= policy.max_attempts.max(1) {
return None;
}
if budget.is_some_and(|b| failures >= b) {
return None;
}
Some(if policy.separate_budgets() {
failures
} else {
attempt
})
}
pub(crate) fn transport_failed(&mut self, policy: &RetryPolicy, attempt: u32) -> Option<u32> {
self.transport = self.transport.saturating_add(1);
self.may_retry(
policy,
attempt,
self.transport,
policy.max_transport_attempts,
)
}
pub(crate) fn status_failed(&mut self, policy: &RetryPolicy, attempt: u32) -> Option<u32> {
self.status = self.status.saturating_add(1);
self.may_retry(policy, attempt, self.status, policy.max_status_attempts)
}
}
fn random_unit() -> f64 {
let mut b = [0u8; 4];
if getrandom::fill(&mut b).is_err() {
return 0.0;
}
u32::from_le_bytes(b) as f64 / u32::MAX as f64
}
pub fn parse_retry_after(value: &str) -> Option<Duration> {
parse_retry_after_at(value, SystemTime::now())
}
pub fn parse_retry_after_at(value: &str, now: SystemTime) -> Option<Duration> {
let v = value.trim();
if let Ok(s) = v.parse::<f64>() {
return Some(s)
.filter(|s| s.is_finite() && *s >= 0.0)
.map(|s| Duration::try_from_secs_f64(s).unwrap_or(Duration::MAX));
}
let at = parse_http_date(v)?;
Some(at.duration_since(now).unwrap_or(Duration::ZERO))
}
pub fn parse_rate_limit_reset_at(value: &str, now: SystemTime) -> Option<Duration> {
let v = value.trim();
let at = if !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()) {
let secs: u64 = v.parse().ok()?;
UNIX_EPOCH.checked_add(Duration::from_secs(secs))?
} else {
parse_iso8601_instant(v)?
};
Some(at.duration_since(now).unwrap_or(Duration::ZERO))
}
pub fn parse_rate_limit_reset(value: &str) -> Option<Duration> {
parse_rate_limit_reset_at(value, SystemTime::now())
}
fn parse_iso8601_instant(v: &str) -> Option<SystemTime> {
let b = v.as_bytes();
if b.len() < 20 {
return None;
}
let num = |s: Option<&str>| -> Option<i64> {
let s = s?;
if !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit()) {
s.parse().ok()
} else {
None
}
};
if b[4] != b'-' || b[7] != b'-' || !matches!(b[10], b'T' | b't' | b' ') {
return None;
}
if b[13] != b':' || b[16] != b':' {
return None;
}
let year = num(v.get(0..4))?;
let month = num(v.get(5..7))?;
let day = num(v.get(8..10))?;
let hour = num(v.get(11..13))?;
let minute = num(v.get(14..16))?;
let second = num(v.get(17..19))?;
if !(1..=12).contains(&month)
|| !(1..=31).contains(&day)
|| hour > 23
|| minute > 59
|| second > 60
{
return None;
}
let mut i = 19;
if b.get(i) == Some(&b'.') {
i += 1;
let start = i;
while b.get(i).is_some_and(|c| c.is_ascii_digit()) {
i += 1;
}
if i == start {
return None;
}
}
let offset: i64 = match *b.get(i)? {
b'Z' | b'z' => {
i += 1;
0
}
sign @ (b'+' | b'-') => {
if b.get(i + 3) != Some(&b':') {
return None;
}
let oh = num(v.get(i + 1..i + 3))?;
let om = num(v.get(i + 4..i + 6))?;
i += 6;
let secs = oh * 3600 + om * 60;
if sign == b'-' {
-secs
} else {
secs
}
}
_ => return None,
};
if i != b.len() {
return None;
}
let days = days_from_civil(year, month, day);
let secs = days * 86_400 + hour * 3_600 + minute * 60 + second - offset;
if secs < 0 {
return None;
}
Some(UNIX_EPOCH + Duration::from_secs(secs as u64))
}
fn month_number(m: &str) -> Option<i64> {
const MONTHS: [&str; 12] = [
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
];
let m = m.get(..3)?.to_ascii_lowercase();
MONTHS.iter().position(|x| *x == m).map(|i| i as i64 + 1)
}
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
pub(crate) fn parse_http_date(v: &str) -> Option<SystemTime> {
let t: Vec<&str> = v.split_whitespace().collect();
let (day, mon, year, time) = match t.len() {
6 if t[0].ends_with(',') && t[5].eq_ignore_ascii_case("GMT") => {
(t[1], t[2], t[3].parse::<i64>().ok()?, t[4])
}
4 if t[0].ends_with(',') && t[3].eq_ignore_ascii_case("GMT") => {
let mut p = t[1].split('-');
let (d, m, y) = (p.next()?, p.next()?, p.next()?);
if p.next().is_some() {
return None;
}
let yy = y.parse::<i64>().ok()?;
let year = if y.len() == 2 {
if yy < 70 {
2000 + yy
} else {
1900 + yy
}
} else {
yy
};
(d, m, year, t[2])
}
5 => (t[2], t[1], t[4].parse::<i64>().ok()?, t[3]),
_ => return None,
};
let day = day.parse::<i64>().ok()?;
let month = month_number(mon)?;
let mut hms = time.split(':');
let h = hms.next()?.parse::<i64>().ok()?;
let mi = hms.next()?.parse::<i64>().ok()?;
let s = hms.next()?.parse::<i64>().ok()?;
if hms.next().is_some()
|| !(1..=31).contains(&day)
|| !(0..24).contains(&h)
|| !(0..60).contains(&mi)
|| !(0..=60).contains(&s)
|| !(1970..=9999).contains(&year)
{
return None;
}
let secs = days_from_civil(year, month, day) * 86_400 + h * 3600 + mi * 60 + s;
u64::try_from(secs)
.ok()
.map(|s| UNIX_EPOCH + Duration::from_secs(s))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn huge_retry_after_saturates_instead_of_panicking() {
for v in [
"18446744073709551616",
"1e300",
"99999999999999999999999999999999999999",
"1.7976931348623157e308",
] {
assert_eq!(parse_retry_after(v), Some(Duration::MAX), "{v}");
}
assert_eq!(parse_retry_after("1.5"), Some(Duration::from_millis(1500)));
assert_eq!(parse_retry_after("-1"), None);
assert_eq!(parse_retry_after("NaN"), None);
assert_eq!(parse_retry_after("inf"), None);
}
#[test]
fn delay_caps_a_saturated_retry_after() {
let p = RetryPolicy {
max_retry_after: Duration::from_secs(30),
jitter: 0.0,
..RetryPolicy::default()
};
assert_eq!(p.delay(1, Some(Duration::MAX)), Duration::from_secs(30));
}
#[test]
fn backoff_with_absurd_settings_does_not_panic() {
let p = RetryPolicy {
initial_backoff: Duration::MAX,
max_backoff: Duration::MAX,
multiplier: f64::MAX,
jitter: 0.0,
..RetryPolicy::default()
};
let _ = p.backoff(40);
}
#[test]
fn retry_after_http_date_forms() {
let at = UNIX_EPOCH + Duration::from_secs(784_111_777); let now = at - Duration::from_secs(90);
for v in [
"Sun, 06 Nov 1994 08:49:37 GMT",
"Sunday, 06-Nov-94 08:49:37 GMT",
"Sun Nov 6 08:49:37 1994",
] {
assert_eq!(
parse_retry_after_at(v, now),
Some(Duration::from_secs(90)),
"{v}"
);
}
assert_eq!(
parse_retry_after_at("Sun, 06 Nov 1994 08:49:37 GMT", at + Duration::from_secs(5)),
Some(Duration::ZERO)
);
assert_eq!(parse_retry_after_at("soon", now), None);
assert_eq!(
parse_retry_after_at("Sun, 32 Nov 1994 08:49:37 GMT", now),
None
);
}
#[test]
fn retry_after_beyond_the_cap_is_kept_whole_and_defers_on_request() {
assert_eq!(parse_retry_after("3600"), Some(Duration::from_secs(3600)));
let mut p = RetryPolicy::default();
assert!(!p.defers(Some(Duration::from_secs(3600))));
p.defer_over_cap = true;
assert!(p.defers(Some(Duration::from_secs(3600))));
assert!(!p.defers(Some(Duration::from_secs(10))));
assert!(!p.defers(None));
p.honor_retry_after = false;
assert!(!p.defers(Some(Duration::from_secs(3600))));
}
#[test]
fn separate_budgets_do_not_steal_from_each_other_and_stay_finite() {
let policy = RetryPolicy {
max_attempts: 100,
max_transport_attempts: Some(2),
max_status_attempts: Some(3),
..RetryPolicy::default()
};
let mut b = AttemptBudgets::new();
assert_eq!(b.transport_failed(&policy, 1), Some(1));
assert_eq!(b.transport_failed(&policy, 2), None);
assert_eq!(b.status_failed(&policy, 3), Some(1));
assert_eq!(b.status_failed(&policy, 4), Some(2));
assert_eq!(b.status_failed(&policy, 5), None);
assert_eq!(b.transport_failed(&policy, 6), None);
let capped = RetryPolicy {
max_attempts: 4,
max_transport_attempts: Some(10),
max_status_attempts: Some(10),
..RetryPolicy::default()
};
let mut b = AttemptBudgets::new();
assert!(b.transport_failed(&capped, 1).is_some());
assert!(b.status_failed(&capped, 2).is_some());
assert!(b.transport_failed(&capped, 3).is_some());
assert_eq!(b.status_failed(&capped, 4), None);
let legacy = RetryPolicy {
max_attempts: 3,
..RetryPolicy::default()
};
assert!(!legacy.separate_budgets());
let mut b = AttemptBudgets::new();
assert_eq!(b.transport_failed(&legacy, 1), Some(1));
assert_eq!(b.status_failed(&legacy, 2), Some(2));
assert_eq!(b.transport_failed(&legacy, 3), None);
}
#[test]
fn rate_limit_reset_epoch_and_iso_forms() {
let now = UNIX_EPOCH + Duration::from_secs(1_792_317_600 - 90);
let want = Some(Duration::from_secs(90));
assert_eq!(parse_rate_limit_reset_at("1792317600", now), want);
assert_eq!(parse_rate_limit_reset_at("2026-10-18T10:00:00Z", now), want);
assert_eq!(
parse_rate_limit_reset_at("2026-10-18T12:00:00+02:00", now),
want
);
assert_eq!(
parse_rate_limit_reset_at("2026-10-18 10:00:00.250Z", now),
Some(Duration::from_secs(90))
);
assert_eq!(
parse_rate_limit_reset_at("2026-10-18T10:00:00-02:00", now),
Some(Duration::from_secs(90 + 7200))
);
assert_eq!(parse_rate_limit_reset_at("1000", now), Some(Duration::ZERO));
assert_eq!(parse_rate_limit_reset_at("2026-13-18T10:00:00Z", now), None);
assert_eq!(parse_rate_limit_reset_at("2026-10-18T10:00:00", now), None);
assert_eq!(parse_rate_limit_reset_at("soon", now), None);
assert_eq!(parse_rate_limit_reset_at("", now), None);
}
}