use std::time::Duration;
use crate::check::{CheckOutcome, MatchKind, UncertainReason};
#[derive(Debug, Clone)]
pub(crate) struct RetryPolicy {
pub(crate) max_retries: u32,
pub(crate) base_delay: Duration,
pub(crate) max_delay: Duration,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_retries: 2,
base_delay: Duration::from_millis(500),
max_delay: Duration::from_secs(30),
}
}
}
pub(crate) fn should_retry(outcome: &CheckOutcome, attempt: u32, policy: &RetryPolicy) -> bool {
if attempt >= policy.max_retries {
return false;
}
if outcome.kind != MatchKind::Uncertain {
return false;
}
matches!(
outcome.reason,
Some(UncertainReason::RateLimited | UncertainReason::CloudflareChallenge)
)
}
pub(crate) fn backoff_delay(attempt: u32, policy: &RetryPolicy) -> Duration {
let shift = attempt.min(20);
let raw = policy.base_delay.saturating_mul(1u32 << shift);
let capped = raw.min(policy.max_delay);
let jitter = fastrand::f64().mul_add(0.5, -0.25); let secs = capped.as_secs_f64() * (1.0 + jitter);
if secs <= 0.0 {
Duration::ZERO
} else {
Duration::from_secs_f64(secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn outcome_with_reason(reason: Option<UncertainReason>) -> CheckOutcome {
CheckOutcome {
site: "S".into(),
url: "u".into(),
kind: MatchKind::Uncertain,
reason,
elapsed_ms: 0,
enrichment: std::collections::BTreeMap::new(),
evidence: Vec::new(),
}
}
#[test]
fn rate_limited_uncertain_retries_while_attempts_remain() {
let policy = RetryPolicy::default();
let outcome = outcome_with_reason(Some(UncertainReason::RateLimited));
assert!(should_retry(&outcome, 0, &policy));
assert!(should_retry(&outcome, 1, &policy));
assert!(!should_retry(&outcome, 2, &policy));
}
#[test]
fn cloudflare_challenge_retries() {
let policy = RetryPolicy::default();
let outcome = outcome_with_reason(Some(UncertainReason::CloudflareChallenge));
assert!(should_retry(&outcome, 0, &policy));
}
#[test]
fn other_uncertain_reasons_do_not_retry() {
let policy = RetryPolicy::default();
let outcome = outcome_with_reason(Some(UncertainReason::Network("refused".into())));
assert!(!should_retry(&outcome, 0, &policy));
let outcome = outcome_with_reason(Some(UncertainReason::BodyRead("eof".into())));
assert!(!should_retry(&outcome, 0, &policy));
}
#[test]
fn found_and_not_found_do_not_retry() {
let policy = RetryPolicy::default();
let mut outcome = outcome_with_reason(Some(UncertainReason::RateLimited));
outcome.kind = MatchKind::Found;
assert!(!should_retry(&outcome, 0, &policy));
outcome.kind = MatchKind::NotFound;
assert!(!should_retry(&outcome, 0, &policy));
}
#[test]
fn zero_max_retries_never_retries() {
let policy = RetryPolicy {
max_retries: 0,
..Default::default()
};
let outcome = outcome_with_reason(Some(UncertainReason::RateLimited));
assert!(!should_retry(&outcome, 0, &policy));
}
#[test]
fn backoff_grows_exponentially_within_cap() {
fastrand::seed(42);
let policy = RetryPolicy {
max_retries: 5,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(10),
};
let d0 = backoff_delay(0, &policy);
let d1 = backoff_delay(1, &policy);
let d2 = backoff_delay(2, &policy);
assert!(d0 >= Duration::from_millis(70) && d0 <= Duration::from_millis(130));
assert!(d1 >= Duration::from_millis(140) && d1 <= Duration::from_millis(260));
assert!(d2 >= Duration::from_millis(280) && d2 <= Duration::from_millis(520));
}
#[test]
fn backoff_caps_at_max_delay() {
fastrand::seed(7);
let policy = RetryPolicy {
max_retries: 20,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_millis(500),
};
let d = backoff_delay(10, &policy);
assert!(d <= Duration::from_millis(625), "got {d:?}");
assert!(d >= Duration::from_millis(375), "got {d:?}");
}
}