use crate::error::MetadataError;
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct RetryPolicy {
pub max_retries: u32,
pub base_delay: Duration,
pub max_delay: Duration,
pub jitter: bool,
pub retry_idempotent_5xx: bool,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_retries: 3,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(30),
jitter: true,
retry_idempotent_5xx: true,
}
}
}
impl RetryPolicy {
pub fn none() -> Self {
Self {
max_retries: 0,
..Self::default()
}
}
}
pub(crate) fn should_retry_status(
policy: &RetryPolicy,
method: &reqwest::Method,
status: u16,
attempt: u32,
) -> bool {
if attempt >= policy.max_retries {
return false;
}
match status {
429 | 503 => true,
500 | 502 | 504 if policy.retry_idempotent_5xx => is_idempotent(method),
_ => false,
}
}
pub(crate) fn should_retry_network(
policy: &RetryPolicy,
method: &reqwest::Method,
error: &MetadataError,
attempt: u32,
) -> bool {
if attempt >= policy.max_retries {
return false;
}
let MetadataError::Http(http) = error else {
return false;
};
if http.is_connect() {
return true;
}
is_idempotent(method)
}
fn is_idempotent(method: &reqwest::Method) -> bool {
matches!(
*method,
reqwest::Method::GET
| reqwest::Method::HEAD
| reqwest::Method::DELETE
| reqwest::Method::PUT
| reqwest::Method::OPTIONS
| reqwest::Method::TRACE
)
}
pub(crate) fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
let raw = headers.get(reqwest::header::RETRY_AFTER)?;
let s = raw.to_str().ok()?;
s.trim().parse::<u64>().ok().map(Duration::from_secs)
}
pub(crate) fn compute_delay(
policy: &RetryPolicy,
attempt: u32,
retry_after: Option<Duration>,
) -> Duration {
if let Some(hint) = retry_after {
let capped = hint.min(policy.max_delay);
tracing::warn!(
target: "cirrus_metadata::retry",
attempt = attempt + 1,
delay_ms = capped.as_millis() as u64,
source = "retry-after-header",
"scheduling request retry",
);
return capped;
}
let factor: u128 = 1u128.checked_shl(attempt).unwrap_or(u128::MAX);
let computed_ms = policy.base_delay.as_millis().saturating_mul(factor);
let max_ms = policy.max_delay.as_millis();
let capped_ms = computed_ms.min(max_ms);
let computed = Duration::from_millis(capped_ms.min(u64::MAX as u128) as u64);
let final_delay = if !policy.jitter {
computed
} else {
let max_ms = computed.as_millis() as u64;
if max_ms == 0 {
Duration::ZERO
} else {
let mut buf = [0u8; 8];
if getrandom::fill(&mut buf).is_err() {
computed
} else {
let r = u64::from_le_bytes(buf) % (max_ms + 1);
Duration::from_millis(r)
}
}
};
tracing::warn!(
target: "cirrus_metadata::retry",
attempt = attempt + 1,
delay_ms = final_delay.as_millis() as u64,
source = "exponential-backoff",
"scheduling request retry",
);
final_delay
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn default_policy_retries_three_times() {
let p = RetryPolicy::default();
assert_eq!(p.max_retries, 3);
assert!(p.jitter);
}
#[test]
fn none_policy_disables_retry() {
let p = RetryPolicy::none();
assert!(!should_retry_status(&p, &reqwest::Method::POST, 503, 0));
}
#[test]
fn retries_429_and_503_for_post_metadata_calls() {
let p = RetryPolicy::default();
assert!(should_retry_status(&p, &reqwest::Method::POST, 429, 0));
assert!(should_retry_status(&p, &reqwest::Method::POST, 503, 0));
}
#[test]
fn does_not_retry_post_on_other_5xx() {
let p = RetryPolicy::default();
assert!(!should_retry_status(&p, &reqwest::Method::POST, 500, 0));
assert!(!should_retry_status(&p, &reqwest::Method::POST, 502, 0));
assert!(!should_retry_status(&p, &reqwest::Method::POST, 504, 0));
}
#[test]
fn stops_at_max_retries() {
let p = RetryPolicy::default();
assert!(should_retry_status(&p, &reqwest::Method::POST, 429, 2));
assert!(!should_retry_status(&p, &reqwest::Method::POST, 429, 3));
}
#[test]
fn parse_retry_after_handles_seconds() {
let mut h = reqwest::header::HeaderMap::new();
h.insert(
reqwest::header::RETRY_AFTER,
reqwest::header::HeaderValue::from_static("7"),
);
assert_eq!(parse_retry_after(&h), Some(Duration::from_secs(7)));
}
#[test]
fn compute_delay_honors_retry_after_capped_at_max() {
let p = RetryPolicy {
max_delay: Duration::from_secs(10),
..RetryPolicy::default()
};
assert_eq!(
compute_delay(&p, 0, Some(Duration::from_secs(99))),
Duration::from_secs(10)
);
}
#[test]
fn compute_delay_caps_exponential_at_max_delay() {
let p = RetryPolicy {
base_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(1),
jitter: false,
..RetryPolicy::default()
};
assert_eq!(compute_delay(&p, 0, None), Duration::from_millis(100));
assert_eq!(compute_delay(&p, 4, None), Duration::from_secs(1));
assert_eq!(compute_delay(&p, 100, None), Duration::from_secs(1));
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod property_tests {
use super::*;
use proptest::prelude::*;
fn deterministic_policy() -> impl Strategy<Value = RetryPolicy> {
(1u64..=500u64, 1u64..=60_000u64).prop_map(|(base_ms, max_ms)| {
let max_ms = max_ms.max(base_ms);
RetryPolicy {
base_delay: Duration::from_millis(base_ms),
max_delay: Duration::from_millis(max_ms),
jitter: false,
..RetryPolicy::default()
}
})
}
fn jittered_policy() -> impl Strategy<Value = RetryPolicy> {
(1u64..=500u64, 1u64..=60_000u64).prop_map(|(base_ms, max_ms)| {
let max_ms = max_ms.max(base_ms);
RetryPolicy {
base_delay: Duration::from_millis(base_ms),
max_delay: Duration::from_millis(max_ms),
jitter: true,
..RetryPolicy::default()
}
})
}
proptest! {
#[test]
fn compute_delay_respects_max_delay_cap(
policy in jittered_policy(),
attempt in 0u32..=u32::MAX,
hint_ms in proptest::option::of(0u64..=300_000u64),
) {
let hint = hint_ms.map(Duration::from_millis);
let delay = compute_delay(&policy, attempt, hint);
prop_assert!(
delay <= policy.max_delay,
"delay {:?} exceeded max_delay {:?} (attempt={attempt}, hint={hint:?})",
delay,
policy.max_delay,
);
}
#[test]
fn compute_delay_is_monotonic_without_jitter_or_hint(
policy in deterministic_policy(),
a in 0u32..=200,
b in 0u32..=200,
) {
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
let dl = compute_delay(&policy, lo, None);
let dh = compute_delay(&policy, hi, None);
prop_assert!(
dl <= dh,
"non-monotonic: delay({lo})={dl:?} > delay({hi})={dh:?} for policy {policy:?}",
);
}
#[test]
fn compute_delay_with_hint_returns_capped_hint(
policy in deterministic_policy(),
attempt in 0u32..=200,
hint_ms in 0u64..=120_000u64,
) {
let hint = Duration::from_millis(hint_ms);
let delay = compute_delay(&policy, attempt, Some(hint));
prop_assert_eq!(delay, hint.min(policy.max_delay));
}
#[test]
fn compute_delay_does_not_panic_at_overflow_attempts(
policy in deterministic_policy(),
attempt in (u32::MAX - 100)..=u32::MAX,
) {
let delay = compute_delay(&policy, attempt, None);
prop_assert!(delay <= policy.max_delay);
}
}
}