use std::time::{Duration, SystemTime};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StalenessVerdict {
NoPriorGood,
Stale { age_hours: u64 },
Expired { age_hours: u64 },
}
#[must_use]
pub fn classify_staleness(
last_good_at: Option<SystemTime>,
cap_hours: u32,
now: SystemTime,
) -> StalenessVerdict {
let Some(last_good) = last_good_at else {
return StalenessVerdict::NoPriorGood;
};
let age = now.duration_since(last_good).unwrap_or(Duration::ZERO);
let age_hours = age.as_secs() / 3_600;
if cap_hours == 0 || age_hours < u64::from(cap_hours) {
StalenessVerdict::Stale { age_hours }
} else {
StalenessVerdict::Expired { age_hours }
}
}
pub const BACKOFF_SCHEDULE: &[Duration] = &[
Duration::from_secs(30),
Duration::from_secs(60),
Duration::from_secs(120),
Duration::from_secs(300),
Duration::from_secs(600),
];
#[must_use]
pub fn backoff_delay_for(retry_count: u32) -> Duration {
if retry_count == 0 {
return Duration::ZERO;
}
let idx = (retry_count as usize - 1).min(BACKOFF_SCHEDULE.len() - 1);
BACKOFF_SCHEDULE[idx]
}
#[cfg(test)]
mod tests {
use super::*;
fn hours(n: u64) -> Duration {
Duration::from_secs(n * 3_600)
}
#[test]
fn no_prior_good_returns_no_prior_good_verdict() {
assert_eq!(
classify_staleness(None, 24, SystemTime::now()),
StalenessVerdict::NoPriorGood,
);
}
#[test]
fn stale_verdict_when_age_within_cap() {
let now = SystemTime::now();
let last = now - hours(5);
assert_eq!(
classify_staleness(Some(last), 24, now),
StalenessVerdict::Stale { age_hours: 5 },
);
}
#[test]
fn expired_verdict_at_or_past_cap() {
let now = SystemTime::now();
let last = now - hours(24);
assert_eq!(
classify_staleness(Some(last), 24, now),
StalenessVerdict::Expired { age_hours: 24 },
);
let older = now - hours(48);
assert_eq!(
classify_staleness(Some(older), 24, now),
StalenessVerdict::Expired { age_hours: 48 },
);
}
#[test]
fn cap_zero_disables_expiry() {
let now = SystemTime::now();
let ancient = now - hours(10_000);
assert_eq!(
classify_staleness(Some(ancient), 0, now),
StalenessVerdict::Stale { age_hours: 10_000 },
);
}
#[test]
fn clock_skew_backward_reports_zero_age() {
let now = SystemTime::now();
let future = now + hours(1);
assert_eq!(
classify_staleness(Some(future), 24, now),
StalenessVerdict::Stale { age_hours: 0 },
"clock going backward must not report negative or overflow age",
);
}
#[test]
fn backoff_schedule_matches_plan_spec() {
assert_eq!(backoff_delay_for(0), Duration::ZERO);
assert_eq!(backoff_delay_for(1), Duration::from_secs(30));
assert_eq!(backoff_delay_for(2), Duration::from_secs(60));
assert_eq!(backoff_delay_for(3), Duration::from_secs(120));
assert_eq!(backoff_delay_for(4), Duration::from_secs(300));
assert_eq!(backoff_delay_for(5), Duration::from_secs(600));
}
#[test]
fn backoff_clamps_to_last_entry_for_large_retry_counts() {
assert_eq!(backoff_delay_for(6), Duration::from_secs(600));
assert_eq!(backoff_delay_for(100), Duration::from_secs(600));
assert_eq!(backoff_delay_for(u32::MAX), Duration::from_secs(600));
}
}