use std::time::Duration;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RetryStrategyType {
Immediate,
#[default]
Exponential,
Linear,
Constant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RetryPolicy {
#[default]
Default,
Aggressive,
Conservative,
Custom,
}
impl RetryPolicy {
pub fn to_config(&self) -> RetryStrategyConfig {
match self {
RetryPolicy::Default => RetryStrategyConfig {
strategy: RetryStrategyType::Exponential,
max_attempts: 6,
base_delay: Duration::from_secs(2),
max_delay: Duration::from_secs(120),
jitter: 0.5,
},
RetryPolicy::Aggressive => RetryStrategyConfig {
strategy: RetryStrategyType::Exponential,
max_attempts: 10,
base_delay: Duration::from_millis(500),
max_delay: Duration::from_secs(30),
jitter: 0.3,
},
RetryPolicy::Conservative => RetryStrategyConfig {
strategy: RetryStrategyType::Linear,
max_attempts: 3,
base_delay: Duration::from_secs(5),
max_delay: Duration::from_secs(60),
jitter: 0.1,
},
RetryPolicy::Custom => {
RetryStrategyConfig::default()
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetryStrategyConfig {
#[serde(default)]
pub strategy: RetryStrategyType,
#[serde(default)]
pub max_attempts: u32,
#[serde(default = "default_base_delay")]
#[serde(with = "humantime_serde")]
pub base_delay: Duration,
#[serde(default = "default_max_delay")]
#[serde(with = "humantime_serde")]
pub max_delay: Duration,
#[serde(default = "default_jitter")]
pub jitter: f64,
}
fn default_base_delay() -> Duration {
Duration::from_secs(2)
}
fn default_max_delay() -> Duration {
Duration::from_secs(120)
}
fn default_jitter() -> f64 {
0.5
}
impl Default for RetryStrategyConfig {
fn default() -> Self {
Self {
strategy: RetryStrategyType::Exponential,
max_attempts: 6,
base_delay: Duration::from_secs(2),
max_delay: Duration::from_secs(120),
jitter: 0.5,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorClass {
#[default]
Retryable,
Ambiguous,
Permanent,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PerErrorConfig {
#[serde(default, rename = "retryable")]
pub retryable: Option<RetryStrategyConfig>,
#[serde(default, rename = "ambiguous")]
pub ambiguous: Option<RetryStrategyConfig>,
#[serde(default, rename = "permanent")]
pub permanent: Option<RetryStrategyConfig>,
}
pub fn calculate_delay(config: &RetryStrategyConfig, attempt: u32) -> Duration {
let delay = match config.strategy {
RetryStrategyType::Immediate => Duration::ZERO,
RetryStrategyType::Exponential => {
let pow = attempt.saturating_sub(1).min(16);
config.base_delay.saturating_mul(2_u32.saturating_pow(pow))
}
RetryStrategyType::Linear => config.base_delay.saturating_mul(attempt),
RetryStrategyType::Constant => config.base_delay,
};
let capped = delay.min(config.max_delay);
if config.jitter > 0.0 {
apply_jitter(capped, config.jitter)
} else {
capped
}
}
fn apply_jitter(delay: Duration, jitter: f64) -> Duration {
let jitter_range = 2.0 * jitter;
let random_value: f64 = rand::random();
let random_factor = 1.0 - jitter + (random_value * jitter_range);
let millis = (delay.as_millis() as f64 * random_factor).round() as u64;
Duration::from_millis(millis)
}
pub fn config_for_error(
default_config: &RetryStrategyConfig,
per_error_config: Option<&PerErrorConfig>,
error_class: ErrorClass,
) -> RetryStrategyConfig {
if let Some(per_error) = per_error_config {
match error_class {
ErrorClass::Retryable => {
if let Some(config) = &per_error.retryable {
return config.clone();
}
}
ErrorClass::Ambiguous => {
if let Some(config) = &per_error.ambiguous {
return config.clone();
}
}
ErrorClass::Permanent => {
if let Some(config) = &per_error.permanent {
return config.clone();
}
}
}
}
default_config.clone()
}
pub struct RetryExecutor {
config: RetryStrategyConfig,
}
impl RetryExecutor {
pub fn new(config: RetryStrategyConfig) -> Self {
Self { config }
}
pub fn from_policy(policy: RetryPolicy) -> Self {
Self::new(policy.to_config())
}
pub fn run<T, E, F>(&self, mut operation: F) -> Result<T, E>
where
F: FnMut(u32) -> Result<T, E>,
{
let mut attempt = 1;
loop {
match operation(attempt) {
Ok(result) => return Ok(result),
Err(e) => {
if attempt >= self.config.max_attempts {
return Err(e);
}
let delay = calculate_delay(&self.config, attempt);
std::thread::sleep(delay);
attempt += 1;
}
}
}
}
pub fn run_with_classification<T, E, F>(&self, mut operation: F) -> Result<T, E>
where
F: FnMut(u32) -> Result<(T, bool), E>,
{
let mut attempt = 1;
loop {
match operation(attempt) {
Ok((result, _)) => return Ok(result),
Err(e) => {
if attempt >= self.config.max_attempts {
return Err(e);
}
let delay = calculate_delay(&self.config, attempt);
std::thread::sleep(delay);
attempt += 1;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_retry_policy_to_config_default() {
let config = RetryPolicy::Default.to_config();
assert_eq!(config.strategy, RetryStrategyType::Exponential);
assert_eq!(config.max_attempts, 6);
assert_eq!(config.base_delay, Duration::from_secs(2));
assert_eq!(config.max_delay, Duration::from_secs(120));
}
#[test]
fn test_retry_policy_to_config_aggressive() {
let config = RetryPolicy::Aggressive.to_config();
assert_eq!(config.strategy, RetryStrategyType::Exponential);
assert_eq!(config.max_attempts, 10);
assert_eq!(config.base_delay, Duration::from_millis(500));
assert_eq!(config.max_delay, Duration::from_secs(30));
}
#[test]
fn test_retry_policy_to_config_conservative() {
let config = RetryPolicy::Conservative.to_config();
assert_eq!(config.strategy, RetryStrategyType::Linear);
assert_eq!(config.max_attempts, 3);
assert_eq!(config.base_delay, Duration::from_secs(5));
assert_eq!(config.max_delay, Duration::from_secs(60));
}
#[test]
fn test_calculate_delay_immediate() {
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Immediate,
base_delay: Duration::from_secs(1),
max_delay: Duration::from_secs(60),
jitter: 0.0,
max_attempts: 3,
};
assert_eq!(calculate_delay(&config, 1), Duration::ZERO);
assert_eq!(calculate_delay(&config, 5), Duration::ZERO);
}
#[test]
fn test_calculate_delay_exponential() {
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Exponential,
base_delay: Duration::from_secs(1),
max_delay: Duration::from_secs(60),
jitter: 0.0,
max_attempts: 10,
};
assert_eq!(calculate_delay(&config, 1), Duration::from_secs(1));
assert_eq!(calculate_delay(&config, 2), Duration::from_secs(2));
assert_eq!(calculate_delay(&config, 3), Duration::from_secs(4));
assert_eq!(calculate_delay(&config, 10), Duration::from_secs(60));
}
#[test]
fn test_calculate_delay_linear() {
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Linear,
base_delay: Duration::from_secs(1),
max_delay: Duration::from_secs(10),
jitter: 0.0,
max_attempts: 10,
};
assert_eq!(calculate_delay(&config, 1), Duration::from_secs(1));
assert_eq!(calculate_delay(&config, 2), Duration::from_secs(2));
assert_eq!(calculate_delay(&config, 5), Duration::from_secs(5));
assert_eq!(calculate_delay(&config, 15), Duration::from_secs(10));
}
#[test]
fn test_calculate_delay_constant() {
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Constant,
base_delay: Duration::from_secs(2),
max_delay: Duration::from_secs(10),
jitter: 0.0,
max_attempts: 10,
};
assert_eq!(calculate_delay(&config, 1), Duration::from_secs(2));
assert_eq!(calculate_delay(&config, 5), Duration::from_secs(2));
assert_eq!(calculate_delay(&config, 10), Duration::from_secs(2));
}
#[test]
fn test_calculate_delay_capped_at_max() {
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Exponential,
base_delay: Duration::from_secs(10),
max_delay: Duration::from_secs(30),
jitter: 0.0,
max_attempts: 10,
};
assert_eq!(calculate_delay(&config, 1), Duration::from_secs(10));
assert_eq!(calculate_delay(&config, 2), Duration::from_secs(20));
assert_eq!(calculate_delay(&config, 3), Duration::from_secs(30));
assert_eq!(calculate_delay(&config, 10), Duration::from_secs(30));
}
#[test]
fn test_config_for_error_uses_defaults() {
let default_config = RetryStrategyConfig {
strategy: RetryStrategyType::Exponential,
max_attempts: 5,
base_delay: Duration::from_secs(1),
max_delay: Duration::from_secs(30),
jitter: 0.5,
};
let result = config_for_error(&default_config, None, ErrorClass::Retryable);
assert_eq!(result.max_attempts, 5);
let result = config_for_error(&default_config, None, ErrorClass::Permanent);
assert_eq!(result.max_attempts, 5);
}
#[test]
fn test_config_for_error_uses_per_error() {
let default_config = RetryStrategyConfig::default();
let per_error = PerErrorConfig {
retryable: Some(RetryStrategyConfig {
strategy: RetryStrategyType::Immediate,
max_attempts: 10,
base_delay: Duration::ZERO,
max_delay: Duration::ZERO,
jitter: 0.0,
}),
ambiguous: None,
permanent: None,
};
let result = config_for_error(&default_config, Some(&per_error), ErrorClass::Retryable);
assert_eq!(result.strategy, RetryStrategyType::Immediate);
let result = config_for_error(&default_config, Some(&per_error), ErrorClass::Ambiguous);
assert_eq!(result.strategy, RetryStrategyType::Exponential);
}
#[test]
fn test_retry_executor_success_on_first_try() {
let executor = RetryExecutor::from_policy(RetryPolicy::Aggressive);
let result = executor.run(|_attempt| Ok::<_, &str>("success"));
assert_eq!(result, Ok("success"));
}
#[test]
fn test_retry_executor_success_after_retries() {
let executor = RetryExecutor::new(RetryStrategyConfig {
strategy: RetryStrategyType::Immediate,
max_attempts: 5,
base_delay: Duration::ZERO,
max_delay: Duration::ZERO,
jitter: 0.0,
});
let mut attempts = 0;
let result = executor.run(|attempt| {
attempts = attempt;
if attempt < 3 {
Err("transient error")
} else {
Ok("success")
}
});
assert_eq!(result, Ok("success"));
assert_eq!(attempts, 3);
}
#[test]
fn test_retry_executor_fails_after_max_attempts() {
let executor = RetryExecutor::new(RetryStrategyConfig {
strategy: RetryStrategyType::Immediate,
max_attempts: 3,
base_delay: Duration::ZERO,
max_delay: Duration::ZERO,
jitter: 0.0,
});
let result = executor.run(|_attempt| Err::<&str, _>("permanent error"));
assert_eq!(result, Err("permanent error"));
}
#[test]
fn test_jitter_applied_correctly() {
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Constant,
base_delay: Duration::from_secs(10),
max_delay: Duration::from_secs(60),
jitter: 0.5,
max_attempts: 10,
};
for _ in 0..100 {
let delay = calculate_delay(&config, 1);
assert!(delay >= Duration::from_millis(5000));
assert!(delay <= Duration::from_millis(15000));
}
}
#[test]
fn test_zero_max_retries_does_not_retry() {
let executor = RetryExecutor::new(RetryStrategyConfig {
strategy: RetryStrategyType::Immediate,
max_attempts: 0,
base_delay: Duration::ZERO,
max_delay: Duration::ZERO,
jitter: 0.0,
});
let mut call_count = 0u32;
let result = executor.run(|_attempt| {
call_count += 1;
Err::<(), _>("fail")
});
assert_eq!(result, Err("fail"));
assert_eq!(call_count, 1);
}
#[test]
fn test_zero_max_retries_succeeds_on_first_try() {
let executor = RetryExecutor::new(RetryStrategyConfig {
strategy: RetryStrategyType::Exponential,
max_attempts: 0,
base_delay: Duration::from_secs(1),
max_delay: Duration::from_secs(60),
jitter: 0.0,
});
let result = executor.run(|_| Ok::<_, &str>("ok"));
assert_eq!(result, Ok("ok"));
}
#[test]
fn test_very_large_max_retries_immediate_success() {
let executor = RetryExecutor::new(RetryStrategyConfig {
strategy: RetryStrategyType::Exponential,
max_attempts: 100,
base_delay: Duration::from_secs(60),
max_delay: Duration::from_secs(3600),
jitter: 0.5,
});
let mut call_count = 0u32;
let result = executor.run(|_attempt| {
call_count += 1;
Ok::<_, &str>("first try")
});
assert_eq!(result, Ok("first try"));
assert_eq!(call_count, 1);
}
#[test]
fn test_backoff_overflow_exponential_saturates() {
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Exponential,
base_delay: Duration::from_secs(u64::MAX / 2),
max_delay: Duration::from_secs(u64::MAX / 2),
jitter: 0.0,
max_attempts: 100,
};
let delay = calculate_delay(&config, 17);
assert!(delay <= config.max_delay);
}
#[test]
fn test_backoff_overflow_linear_saturates() {
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Linear,
base_delay: Duration::from_secs(u64::MAX / 2),
max_delay: Duration::from_secs(100),
jitter: 0.0,
max_attempts: 100,
};
let delay = calculate_delay(&config, u32::MAX);
assert!(delay <= config.max_delay);
}
#[test]
fn test_jitter_zero_produces_exact_delays() {
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Exponential,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(60),
jitter: 0.0,
max_attempts: 10,
};
for attempt in 1..=5 {
let first = calculate_delay(&config, attempt);
for _ in 0..50 {
assert_eq!(calculate_delay(&config, attempt), first);
}
}
}
#[test]
fn test_jitter_one_full_range() {
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Constant,
base_delay: Duration::from_secs(10),
max_delay: Duration::from_secs(60),
jitter: 1.0,
max_attempts: 10,
};
for _ in 0..200 {
let delay = calculate_delay(&config, 1);
assert!(delay <= Duration::from_millis(20_000));
}
}
#[test]
fn test_initial_delay_zero() {
for strategy in [
RetryStrategyType::Exponential,
RetryStrategyType::Linear,
RetryStrategyType::Constant,
RetryStrategyType::Immediate,
] {
let config = RetryStrategyConfig {
strategy,
base_delay: Duration::ZERO,
max_delay: Duration::from_secs(60),
jitter: 0.0,
max_attempts: 10,
};
for attempt in 1..=5 {
assert_eq!(
calculate_delay(&config, attempt),
Duration::ZERO,
"strategy {:?} attempt {} should be zero with base_delay=ZERO",
strategy,
attempt
);
}
}
}
#[test]
fn test_max_delay_zero_caps_everything() {
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Exponential,
base_delay: Duration::from_secs(10),
max_delay: Duration::ZERO,
jitter: 0.0,
max_attempts: 10,
};
for attempt in 1..=10 {
assert_eq!(
calculate_delay(&config, attempt),
Duration::ZERO,
"attempt {} should be capped to zero by max_delay=ZERO",
attempt
);
}
}
#[test]
fn test_max_delay_less_than_initial_delay() {
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Exponential,
base_delay: Duration::from_secs(10),
max_delay: Duration::from_secs(5),
jitter: 0.0,
max_attempts: 10,
};
for attempt in 1..=5 {
assert_eq!(
calculate_delay(&config, attempt),
Duration::from_secs(5),
"attempt {} should be capped at max_delay when max_delay < base_delay",
attempt
);
}
let linear = RetryStrategyConfig {
strategy: RetryStrategyType::Linear,
..config.clone()
};
assert_eq!(calculate_delay(&linear, 1), Duration::from_secs(5));
let constant = RetryStrategyConfig {
strategy: RetryStrategyType::Constant,
..config
};
assert_eq!(calculate_delay(&constant, 1), Duration::from_secs(5));
}
#[test]
fn test_exponential_growth_doubles_each_attempt() {
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Exponential,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(3600),
jitter: 0.0,
max_attempts: 20,
};
for attempt in 2..=10 {
let prev = calculate_delay(&config, attempt - 1);
let curr = calculate_delay(&config, attempt);
assert_eq!(
curr,
prev * 2,
"attempt {} should be double attempt {}",
attempt,
attempt - 1
);
}
}
#[test]
fn test_exponential_pow_clamped_at_16() {
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Exponential,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_secs(3600),
jitter: 0.0,
max_attempts: 30,
};
let at_17 = calculate_delay(&config, 17);
let at_18 = calculate_delay(&config, 18);
let at_25 = calculate_delay(&config, 25);
assert_eq!(at_17, at_18);
assert_eq!(at_17, at_25);
assert_eq!(at_17, Duration::from_millis(65536));
}
#[test]
fn test_strategy_selection_produces_distinct_delays() {
let base = Duration::from_secs(2);
let max = Duration::from_secs(3600);
let make = |s| RetryStrategyConfig {
strategy: s,
base_delay: base,
max_delay: max,
jitter: 0.0,
max_attempts: 10,
};
let attempt = 3;
let imm = calculate_delay(&make(RetryStrategyType::Immediate), attempt);
let exp = calculate_delay(&make(RetryStrategyType::Exponential), attempt);
let lin = calculate_delay(&make(RetryStrategyType::Linear), attempt);
let con = calculate_delay(&make(RetryStrategyType::Constant), attempt);
assert_eq!(imm, Duration::ZERO);
assert_eq!(exp, Duration::from_secs(8));
assert_eq!(lin, Duration::from_secs(6));
assert_eq!(con, Duration::from_secs(2));
let vals = [imm, exp, lin, con];
for i in 0..vals.len() {
for j in (i + 1)..vals.len() {
assert_ne!(vals[i], vals[j], "strategies {} and {} collided", i, j);
}
}
}
#[test]
fn test_immediate_always_zero_regardless_of_config() {
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Immediate,
base_delay: Duration::from_secs(999),
max_delay: Duration::from_secs(9999),
jitter: 0.0,
max_attempts: 50,
};
for attempt in [1, 5, 10, 50, u32::MAX] {
assert_eq!(calculate_delay(&config, attempt), Duration::ZERO);
}
}
#[test]
fn test_constant_ignores_attempt_number() {
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Constant,
base_delay: Duration::from_millis(750),
max_delay: Duration::from_secs(60),
jitter: 0.0,
max_attempts: 100,
};
let first = calculate_delay(&config, 1);
for attempt in 2..=20 {
assert_eq!(
calculate_delay(&config, attempt),
first,
"constant delay should not change with attempt number"
);
}
}
#[test]
fn test_attempt_zero_does_not_panic() {
for strategy in [
RetryStrategyType::Immediate,
RetryStrategyType::Exponential,
RetryStrategyType::Linear,
RetryStrategyType::Constant,
] {
let config = RetryStrategyConfig {
strategy,
base_delay: Duration::from_secs(1),
max_delay: Duration::from_secs(60),
jitter: 0.0,
max_attempts: 10,
};
let _ = calculate_delay(&config, 0);
}
}
#[test]
fn test_executor_max_attempts_one_no_retry() {
let executor = RetryExecutor::new(RetryStrategyConfig {
strategy: RetryStrategyType::Immediate,
max_attempts: 1,
base_delay: Duration::ZERO,
max_delay: Duration::ZERO,
jitter: 0.0,
});
let mut call_count = 0u32;
let result = executor.run(|_| {
call_count += 1;
Err::<(), _>("fail")
});
assert_eq!(result, Err("fail"));
assert_eq!(call_count, 1, "max_attempts=1 should call exactly once");
}
#[test]
fn test_executor_run_with_classification_success() {
let executor = RetryExecutor::new(RetryStrategyConfig {
strategy: RetryStrategyType::Immediate,
max_attempts: 5,
base_delay: Duration::ZERO,
max_delay: Duration::ZERO,
jitter: 0.0,
});
let result = executor.run_with_classification(|attempt| {
if attempt < 3 {
Err("transient")
} else {
Ok(("done", true))
}
});
assert_eq!(result, Ok("done"));
}
#[test]
fn test_executor_run_with_classification_exhausted() {
let executor = RetryExecutor::new(RetryStrategyConfig {
strategy: RetryStrategyType::Immediate,
max_attempts: 2,
base_delay: Duration::ZERO,
max_delay: Duration::ZERO,
jitter: 0.0,
});
let result =
executor.run_with_classification(|_| Err::<(&str, bool), _>("permanent failure"));
assert_eq!(result, Err("permanent failure"));
}
#[test]
fn test_config_for_error_all_three_overrides() {
let default = RetryStrategyConfig::default();
let per_error = PerErrorConfig {
retryable: Some(RetryStrategyConfig {
max_attempts: 10,
..Default::default()
}),
ambiguous: Some(RetryStrategyConfig {
max_attempts: 20,
..Default::default()
}),
permanent: Some(RetryStrategyConfig {
max_attempts: 30,
..Default::default()
}),
};
assert_eq!(
config_for_error(&default, Some(&per_error), ErrorClass::Retryable).max_attempts,
10
);
assert_eq!(
config_for_error(&default, Some(&per_error), ErrorClass::Ambiguous).max_attempts,
20
);
assert_eq!(
config_for_error(&default, Some(&per_error), ErrorClass::Permanent).max_attempts,
30
);
}
#[test]
fn test_default_config_matches_default_policy() {
let from_default = RetryStrategyConfig::default();
let from_policy = RetryPolicy::Default.to_config();
assert_eq!(from_default.strategy, from_policy.strategy);
assert_eq!(from_default.max_attempts, from_policy.max_attempts);
assert_eq!(from_default.base_delay, from_policy.base_delay);
assert_eq!(from_default.max_delay, from_policy.max_delay);
assert!(
(from_default.jitter - from_policy.jitter).abs() < f64::EPSILON,
"jitter should match"
);
}
#[test]
fn test_jitter_bounds_with_exponential() {
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Exponential,
base_delay: Duration::from_secs(1),
max_delay: Duration::from_secs(3600),
jitter: 0.3,
max_attempts: 10,
};
for _ in 0..200 {
let delay = calculate_delay(&config, 3);
assert!(
delay >= Duration::from_millis(2800),
"delay {:?} below lower bound",
delay
);
assert!(
delay <= Duration::from_millis(5200),
"delay {:?} above upper bound",
delay
);
}
}
#[test]
fn test_jitter_zero_is_deterministic_across_strategies() {
for strategy in [
RetryStrategyType::Exponential,
RetryStrategyType::Linear,
RetryStrategyType::Constant,
] {
let config = RetryStrategyConfig {
strategy,
base_delay: Duration::from_millis(500),
max_delay: Duration::from_secs(120),
jitter: 0.0,
max_attempts: 20,
};
for attempt in 1..=10 {
let a = calculate_delay(&config, attempt);
let b = calculate_delay(&config, attempt);
assert_eq!(a, b, "{:?} attempt {} not deterministic", strategy, attempt);
}
}
}
}
#[cfg(test)]
mod property_tests {
use super::*;
use proptest::prelude::*;
fn expected_exponential(base: Duration, max: Duration, attempt: u32) -> Duration {
let delay = base.saturating_mul(2_u32.saturating_pow(attempt.saturating_sub(1).min(16)));
delay.min(max)
}
fn expected_linear(base: Duration, max: Duration, attempt: u32) -> Duration {
base.saturating_mul(attempt).min(max)
}
proptest! {
#[test]
fn exponential_delay_matches_formula_with_no_jitter(
base_ms in 1u64..10_000,
extra_ms in 0u64..290_000,
attempt in 1u32..40,
) {
let base_delay = Duration::from_millis(base_ms);
let max_delay = Duration::from_millis(base_ms.saturating_add(extra_ms).min(300_000));
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Exponential,
max_attempts: 100,
base_delay,
max_delay,
jitter: 0.0,
};
let expected = expected_exponential(base_delay, max_delay, attempt);
prop_assert_eq!(calculate_delay(&config, attempt), expected);
prop_assert!(calculate_delay(&config, attempt) <= max_delay);
}
#[test]
fn linear_delay_matches_formula_with_no_jitter(
base_ms in 1u64..5_000,
extra_ms in 0u64..295_000,
attempt in 1u32..60,
) {
let base_delay = Duration::from_millis(base_ms);
let max_delay = Duration::from_millis(base_ms.saturating_add(extra_ms).min(300_000));
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Linear,
max_attempts: 100,
base_delay,
max_delay,
jitter: 0.0,
};
let expected = expected_linear(base_delay, max_delay, attempt);
prop_assert_eq!(calculate_delay(&config, attempt), expected);
prop_assert!(calculate_delay(&config, attempt) <= max_delay);
}
#[test]
fn constant_and_immediate_hold_edge_invariants(
base_ms in 0u64..20_000,
extra_ms in 0u64..50_000,
jitter_byte in 0u8..=255,
) {
let base_delay = Duration::from_millis(base_ms);
let max_delay = Duration::from_millis((base_ms + extra_ms).min(300_000));
let jitter = (jitter_byte as f64) / 255.0;
let constant = RetryStrategyConfig {
strategy: RetryStrategyType::Constant,
max_attempts: 100,
base_delay,
max_delay,
jitter,
};
let delay = calculate_delay(&constant, 4);
prop_assert!(delay <= max_delay.saturating_mul(2));
let no_jitter = RetryStrategyConfig {
jitter: 0.0,
..constant.clone()
};
prop_assert_eq!(calculate_delay(&no_jitter, 5), base_delay.min(max_delay));
let immediate = RetryStrategyConfig {
strategy: RetryStrategyType::Immediate,
..constant
};
prop_assert_eq!(calculate_delay(&immediate, 9), Duration::ZERO);
}
#[test]
fn backoff_never_exceeds_max_delay(
strategy_idx in 0u8..4,
base_ms in 0u64..100_000,
max_ms in 0u64..300_000,
attempt in 1u32..100,
) {
let strategy = match strategy_idx {
0 => RetryStrategyType::Immediate,
1 => RetryStrategyType::Exponential,
2 => RetryStrategyType::Linear,
_ => RetryStrategyType::Constant,
};
let max_delay = Duration::from_millis(max_ms);
let config = RetryStrategyConfig {
strategy,
max_attempts: 100,
base_delay: Duration::from_millis(base_ms),
max_delay,
jitter: 0.0,
};
let delay = calculate_delay(&config, attempt);
prop_assert!(
delay <= max_delay,
"strategy={:?} base={}ms max={}ms attempt={} => delay={:?} exceeded max_delay={:?}",
strategy, base_ms, max_ms, attempt, delay, max_delay
);
}
#[test]
fn jitter_delay_within_bounds(
base_ms in 100u64..10_000,
jitter_pct in 1u8..100,
attempt in 1u32..10,
) {
let jitter = (jitter_pct as f64) / 100.0;
let base_delay = Duration::from_millis(base_ms);
let max_delay = Duration::from_secs(3600);
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Constant,
max_attempts: 100,
base_delay,
max_delay,
jitter,
};
let nominal = base_delay.min(max_delay);
let nominal_ms = nominal.as_millis() as f64;
let eps = 1.0;
let low = Duration::from_millis(((nominal_ms * (1.0 - jitter)) - eps).max(0.0) as u64);
let high = Duration::from_millis((nominal_ms * (1.0 + jitter) + eps).ceil() as u64);
for _ in 0..20 {
let delay = calculate_delay(&config, attempt);
prop_assert!(
delay >= low && delay <= high,
"jitter={} base={}ms => delay={:?} outside [{:?}, {:?}]",
jitter, base_ms, delay, low, high
);
}
}
#[test]
fn exponential_delays_non_decreasing_without_jitter(
base_ms in 1u64..5_000,
max_ms in 1u64..300_000,
) {
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Exponential,
max_attempts: 100,
base_delay: Duration::from_millis(base_ms),
max_delay: Duration::from_millis(max_ms),
jitter: 0.0,
};
let mut prev = Duration::ZERO;
for attempt in 1..=20 {
let curr = calculate_delay(&config, attempt);
prop_assert!(
curr >= prev,
"delay decreased at attempt {}: {:?} < {:?}",
attempt, curr, prev
);
prev = curr;
}
}
#[test]
fn delays_never_negative_or_panic(
strategy_idx in 0u8..4,
base_ms in 0u64..u64::MAX / 2,
max_ms in 0u64..300_000,
attempt in 0u32..200,
) {
let strategy = match strategy_idx {
0 => RetryStrategyType::Immediate,
1 => RetryStrategyType::Exponential,
2 => RetryStrategyType::Linear,
_ => RetryStrategyType::Constant,
};
let config = RetryStrategyConfig {
strategy,
max_attempts: 100,
base_delay: Duration::from_millis(base_ms.min(300_000)),
max_delay: Duration::from_millis(max_ms),
jitter: 0.0,
};
let delay = calculate_delay(&config, attempt);
prop_assert!(delay <= Duration::from_millis(max_ms));
}
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
use insta::assert_yaml_snapshot;
#[test]
fn snapshot_default_policy_config() {
assert_yaml_snapshot!(RetryPolicy::Default.to_config());
}
#[test]
fn snapshot_aggressive_policy_config() {
assert_yaml_snapshot!(RetryPolicy::Aggressive.to_config());
}
#[test]
fn snapshot_conservative_policy_config() {
assert_yaml_snapshot!(RetryPolicy::Conservative.to_config());
}
#[test]
fn snapshot_custom_policy_config() {
assert_yaml_snapshot!(RetryPolicy::Custom.to_config());
}
#[test]
fn snapshot_error_classes() {
assert_yaml_snapshot!("retryable", ErrorClass::Retryable);
assert_yaml_snapshot!("ambiguous", ErrorClass::Ambiguous);
assert_yaml_snapshot!("permanent", ErrorClass::Permanent);
}
#[test]
fn snapshot_per_error_config_empty() {
assert_yaml_snapshot!(PerErrorConfig::default());
}
#[test]
fn snapshot_per_error_config_with_retryable_override() {
let config = PerErrorConfig {
retryable: Some(RetryStrategyConfig {
strategy: RetryStrategyType::Immediate,
max_attempts: 10,
base_delay: Duration::ZERO,
max_delay: Duration::from_secs(5),
jitter: 0.0,
}),
ambiguous: None,
permanent: None,
};
assert_yaml_snapshot!(config);
}
#[test]
fn snapshot_delay_sequence_exponential() {
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Exponential,
base_delay: Duration::from_secs(1),
max_delay: Duration::from_secs(60),
jitter: 0.0,
max_attempts: 10,
};
let delays: Vec<String> = (1..=7)
.map(|a| format!("attempt {}: {:?}", a, calculate_delay(&config, a)))
.collect();
assert_yaml_snapshot!(delays);
}
#[test]
fn snapshot_delay_sequence_linear() {
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Linear,
base_delay: Duration::from_secs(2),
max_delay: Duration::from_secs(20),
jitter: 0.0,
max_attempts: 10,
};
let delays: Vec<String> = (1..=10)
.map(|a| format!("attempt {}: {:?}", a, calculate_delay(&config, a)))
.collect();
assert_yaml_snapshot!(delays);
}
#[test]
fn snapshot_strategy_types() {
assert_yaml_snapshot!("immediate", RetryStrategyType::Immediate);
assert_yaml_snapshot!("exponential", RetryStrategyType::Exponential);
assert_yaml_snapshot!("linear", RetryStrategyType::Linear);
assert_yaml_snapshot!("constant", RetryStrategyType::Constant);
}
#[test]
fn snapshot_debug_default_config() {
insta::assert_debug_snapshot!(RetryStrategyConfig::default());
}
#[test]
fn snapshot_debug_all_policies() {
insta::assert_debug_snapshot!("debug_default", RetryPolicy::Default.to_config());
insta::assert_debug_snapshot!("debug_aggressive", RetryPolicy::Aggressive.to_config());
insta::assert_debug_snapshot!("debug_conservative", RetryPolicy::Conservative.to_config());
}
#[test]
fn snapshot_debug_retry_policy_variants() {
insta::assert_debug_snapshot!(vec![
RetryPolicy::Default,
RetryPolicy::Aggressive,
RetryPolicy::Conservative,
RetryPolicy::Custom,
]);
}
#[test]
fn snapshot_delay_sequence_constant() {
let config = RetryStrategyConfig {
strategy: RetryStrategyType::Constant,
base_delay: Duration::from_millis(500),
max_delay: Duration::from_secs(60),
jitter: 0.0,
max_attempts: 10,
};
let delays: Vec<String> = (1..=8)
.map(|a| format!("attempt {}: {:?}", a, calculate_delay(&config, a)))
.collect();
assert_yaml_snapshot!(delays);
}
#[test]
fn snapshot_all_strategies_at_attempt_5() {
let make = |s| RetryStrategyConfig {
strategy: s,
base_delay: Duration::from_secs(1),
max_delay: Duration::from_secs(120),
jitter: 0.0,
max_attempts: 10,
};
let result: Vec<String> = [
RetryStrategyType::Immediate,
RetryStrategyType::Exponential,
RetryStrategyType::Linear,
RetryStrategyType::Constant,
]
.iter()
.map(|&s| format!("{:?}: {:?}", s, calculate_delay(&make(s), 5)))
.collect();
assert_yaml_snapshot!(result);
}
}