#[derive(Debug, PartialEq, Eq)]
pub enum RetryDecision {
RetryAfter(u64 ),
Stop,
}
pub const MAX_ATTEMPTS: u32 = 3;
const BASE_MS: u64 = 500;
const CAP_MS: u64 = 4000;
const MAX_SLEEP_MS: u64 = 3_600_000;
pub fn backoff_ms(attempt: u32) -> u64 {
let exp = BASE_MS.saturating_mul(1u64 << (attempt.saturating_sub(1)));
exp.min(CAP_MS)
}
pub fn clamp_sleep_ms(ms: u64) -> u64 {
ms.min(MAX_SLEEP_MS)
}
pub(crate) fn status_retryable(status: u16) -> bool {
status == 408 || status == 429 || (500..=599).contains(&status)
}
pub fn decide(status: u16, attempt: u32, retry_after_secs: Option<u64>) -> RetryDecision {
if attempt >= MAX_ATTEMPTS || !status_retryable(status) {
return RetryDecision::Stop;
}
let raw_ms = if status == 429 {
retry_after_secs
.map(|s| s * 1000)
.unwrap_or_else(|| backoff_ms(attempt))
} else {
backoff_ms(attempt)
};
RetryDecision::RetryAfter(clamp_sleep_ms(raw_ms))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backoff_is_capped() {
assert_eq!(backoff_ms(1), 500);
assert_eq!(backoff_ms(2), 1000);
assert_eq!(backoff_ms(10), 4000);
}
#[test]
fn stops_at_max_attempts() {
assert_eq!(decide(500, MAX_ATTEMPTS, None), RetryDecision::Stop);
}
#[test]
fn non_retryable_status_stops() {
assert_eq!(decide(404, 1, None), RetryDecision::Stop);
}
#[test]
fn honors_retry_after_on_429() {
assert_eq!(decide(429, 1, Some(2)), RetryDecision::RetryAfter(2000));
}
#[test]
fn retries_5xx_with_backoff() {
assert_eq!(decide(503, 1, None), RetryDecision::RetryAfter(500));
}
}