use std::time::Duration;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub base_delay: f64,
pub max_delay: f64,
pub jitter: f64,
}
impl RetryPolicy {
#[must_use]
pub fn from_millis(
max_attempts: u32,
base_delay_ms: u64,
max_delay_ms: u64,
jitter_ms: u64,
) -> Self {
Self {
max_attempts,
base_delay: Duration::from_millis(base_delay_ms).as_secs_f64(),
max_delay: Duration::from_millis(max_delay_ms).as_secs_f64(),
jitter: Duration::from_millis(jitter_ms).as_secs_f64(),
}
}
#[must_use]
pub const fn verification_default() -> Self {
Self {
max_attempts: 4,
base_delay: 0.5,
max_delay: 4.0,
jitter: 0.5,
}
}
pub fn validate(&self) -> Result<(), String> {
if self.max_attempts < 1 {
return Err(format!(
"max_attempts must be >= 1, got {}",
self.max_attempts
));
}
if self.base_delay < 0.0 {
return Err(format!("base_delay must be >= 0, got {}", self.base_delay));
}
if self.max_delay < 0.0 {
return Err(format!("max_delay must be >= 0, got {}", self.max_delay));
}
if !(0.0..=1.0).contains(&self.jitter) {
return Err(format!("jitter must be in [0, 1], got {}", self.jitter));
}
if self.base_delay > self.max_delay && self.max_delay > 0.0 {
return Err(format!(
"base_delay ({}) must be <= max_delay ({})",
self.base_delay, self.max_delay
));
}
Ok(())
}
#[must_use]
pub fn capped_backoff_secs(&self, attempt: u32) -> f64 {
let exponent = i32::try_from(attempt.saturating_sub(1)).unwrap_or(i32::MAX);
(self.base_delay * 2f64.powi(exponent)).min(self.max_delay)
}
#[must_use]
pub fn capped_backoff(&self, attempt: u32) -> Duration {
Duration::from_secs_f64(self.capped_backoff_secs(attempt).max(0.0))
}
}
#[cfg(test)]
#[expect(clippy::unwrap_used, reason = "tests assert on known-valid inputs")]
mod tests {
use super::*;
#[test]
fn verification_default_is_valid() {
assert!(RetryPolicy::verification_default().validate().is_ok());
}
#[test]
fn validate_rejects_out_of_range_knobs() {
let zero = RetryPolicy {
max_attempts: 0,
..RetryPolicy::verification_default()
};
assert!(zero.validate().unwrap_err().contains("max_attempts"));
let bad_jitter = RetryPolicy {
jitter: 1.5,
..RetryPolicy::verification_default()
};
assert!(bad_jitter.validate().unwrap_err().contains("jitter"));
let negative = RetryPolicy {
base_delay: -0.1,
..RetryPolicy::verification_default()
};
assert!(negative.validate().unwrap_err().contains("base_delay"));
let inverted = RetryPolicy {
base_delay: 5.0,
max_delay: 1.0,
..RetryPolicy::verification_default()
};
assert!(inverted.validate().unwrap_err().contains("base_delay"));
}
#[test]
fn backoff_is_exponential_and_capped() {
let policy = RetryPolicy {
max_attempts: 6,
base_delay: 0.5,
max_delay: 4.0,
jitter: 0.0,
};
assert!((policy.capped_backoff_secs(1) - 0.5).abs() < f64::EPSILON);
assert!((policy.capped_backoff_secs(2) - 1.0).abs() < f64::EPSILON);
assert!((policy.capped_backoff_secs(3) - 2.0).abs() < f64::EPSILON);
assert!((policy.capped_backoff_secs(4) - 4.0).abs() < f64::EPSILON);
assert!((policy.capped_backoff_secs(9) - 4.0).abs() < f64::EPSILON);
}
#[test]
fn from_millis_preserves_the_ms_curve() {
let policy = RetryPolicy::from_millis(10, 100, 30_000, 100);
assert_eq!(policy.capped_backoff(1), Duration::from_millis(100));
assert_eq!(policy.capped_backoff(2), Duration::from_millis(200));
assert_eq!(policy.capped_backoff(9), Duration::from_millis(25_600));
assert_eq!(policy.capped_backoff(10), Duration::from_secs(30));
assert_eq!(policy.capped_backoff(u32::MAX), Duration::from_secs(30));
}
}