use std::sync::Arc;
use std::time::Duration;
use crate::error::Error;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct RetryPolicy {
pub max_retries: u32,
pub initial_backoff: Duration,
pub multiplier: f64,
pub max_backoff: Duration,
pub jitter: bool,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_retries: 3,
initial_backoff: Duration::from_millis(100),
multiplier: 2.0,
max_backoff: Duration::from_secs(30),
jitter: true,
}
}
}
impl RetryPolicy {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn max_retries(mut self, retries: u32) -> Self {
self.max_retries = retries;
self
}
#[must_use]
pub fn initial_backoff(mut self, backoff: Duration) -> Self {
self.initial_backoff = backoff;
self
}
#[must_use]
pub fn multiplier(mut self, multiplier: f64) -> Self {
self.multiplier = multiplier;
self
}
#[must_use]
pub fn max_backoff(mut self, max: Duration) -> Self {
self.max_backoff = max;
self
}
#[must_use]
pub fn jitter(mut self, jitter: bool) -> Self {
self.jitter = jitter;
self
}
pub(crate) fn max_attempts(&self) -> u32 {
self.max_retries.saturating_add(1)
}
pub(crate) fn backoff_at(&self, retry_index: u32) -> Duration {
if self.initial_backoff.is_zero() {
return Duration::ZERO;
}
let multiplier = if self.multiplier.is_finite() && self.multiplier > 1.0 {
self.multiplier
} else {
1.0
};
if multiplier == 1.0 || retry_index == 0 {
return self.initial_backoff.min(self.max_backoff);
}
let exponent = retry_index.min(i32::MAX as u32) as i32;
let secs = (self.initial_backoff.as_secs_f64() * multiplier.powi(exponent))
.min(self.max_backoff.as_secs_f64());
Duration::try_from_secs_f64(secs).unwrap_or(self.max_backoff)
}
pub(crate) fn delay_for(&self, retry_index: u32) -> Duration {
let base = self.backoff_at(retry_index);
if self.jitter && !base.is_zero() {
base.mul_f64(jitter_scale())
} else {
base
}
}
}
fn jitter_scale() -> f64 {
use std::cell::Cell;
thread_local! {
static STATE: Cell<u64> = Cell::new(jitter_seed());
}
STATE.with(|state| {
let mut x = state.get();
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
state.set(x);
(x >> 11) as f64 / (1u64 << 53) as f64
})
}
fn jitter_seed() -> u64 {
use std::hash::{BuildHasher, Hasher};
let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
hasher.write_u64(0x9E37_79B9_7F4A_7C15);
hasher.finish() | 1 }
#[derive(Clone)]
pub(crate) struct RetryConfig {
pub(crate) policy: RetryPolicy,
pub(crate) classifier: Arc<dyn Fn(&Error) -> bool + Send + Sync>,
}
impl RetryConfig {
pub(crate) fn fixed(
max_attempts: u32,
backoff: Duration,
retry_if: impl Fn(&Error) -> bool + Send + Sync + 'static,
) -> Self {
Self {
policy: RetryPolicy {
max_retries: max_attempts.saturating_sub(1),
initial_backoff: backoff,
multiplier: 1.0,
max_backoff: backoff,
jitter: false,
},
classifier: Arc::new(retry_if),
}
}
pub(crate) fn new(
policy: RetryPolicy,
retry_if: impl Fn(&Error) -> bool + Send + Sync + 'static,
) -> Self {
Self {
policy,
classifier: Arc::new(retry_if),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_schedule_is_sensible() {
let p = RetryPolicy::default();
assert_eq!(p.max_retries, 3);
assert_eq!(p.max_attempts(), 4);
assert_eq!(p.initial_backoff, Duration::from_millis(100));
assert_eq!(p.multiplier, 2.0);
assert!(p.jitter);
}
#[test]
fn backoff_at_is_exponential_and_capped() {
let p = RetryPolicy::new()
.initial_backoff(Duration::from_millis(100))
.multiplier(2.0)
.max_backoff(Duration::from_millis(450));
assert_eq!(p.backoff_at(0), Duration::from_millis(100)); assert_eq!(p.backoff_at(1), Duration::from_millis(200)); assert_eq!(p.backoff_at(2), Duration::from_millis(400)); assert_eq!(p.backoff_at(3), Duration::from_millis(450)); assert_eq!(p.backoff_at(50), Duration::from_millis(450)); }
#[test]
fn multiplier_one_is_fixed_backoff() {
let p = RetryPolicy::new()
.initial_backoff(Duration::from_millis(50))
.multiplier(1.0)
.max_backoff(Duration::from_secs(10))
.jitter(false);
for i in 0..5 {
assert_eq!(p.backoff_at(i), Duration::from_millis(50));
assert_eq!(p.delay_for(i), Duration::from_millis(50));
}
}
#[test]
fn sub_unit_multiplier_does_not_shrink_backoff() {
let p = RetryPolicy::new()
.initial_backoff(Duration::from_millis(100))
.multiplier(0.5); assert_eq!(p.backoff_at(3), Duration::from_millis(100));
}
#[test]
fn fixed_config_preserves_command_retry_semantics() {
let cfg = RetryConfig::fixed(3, Duration::from_millis(20), |_| true);
assert_eq!(cfg.policy.max_attempts(), 3);
for i in 0..4 {
assert_eq!(cfg.policy.delay_for(i), Duration::from_millis(20));
}
}
#[test]
fn jitter_stays_within_zero_and_base() {
let p = RetryPolicy::new()
.initial_backoff(Duration::from_millis(1000))
.multiplier(1.0)
.max_backoff(Duration::from_secs(10))
.jitter(true);
let base = p.backoff_at(0);
let mut seen_low = false;
let mut seen_high = false;
for _ in 0..200 {
let d = p.delay_for(0);
assert!(d <= base, "jitter exceeded base: {d:?} > {base:?}");
if d < base / 2 {
seen_low = true;
}
if d > base / 2 {
seen_high = true;
}
}
assert!(
seen_low && seen_high,
"jitter did not spread across the range"
);
}
#[test]
fn zero_backoff_never_waits_even_with_jitter() {
let p = RetryPolicy::new()
.initial_backoff(Duration::ZERO)
.multiplier(2.0)
.jitter(true);
assert_eq!(p.delay_for(0), Duration::ZERO);
assert_eq!(p.delay_for(5), Duration::ZERO);
assert_eq!(p.backoff_at(2000), Duration::ZERO);
assert_eq!(p.backoff_at(u32::MAX), Duration::ZERO);
}
#[test]
fn non_finite_or_nonpositive_multiplier_is_treated_as_fixed() {
let cap = Duration::from_secs(5);
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 0.0, 0.5] {
let p = RetryPolicy::new()
.initial_backoff(Duration::from_millis(100))
.multiplier(bad)
.max_backoff(cap);
assert_eq!(p.backoff_at(0), Duration::from_millis(100), "bad = {bad}");
assert_eq!(p.backoff_at(3), Duration::from_millis(100), "bad = {bad}");
}
}
#[test]
fn fixed_config_attempt_count_boundaries() {
assert_eq!(
RetryConfig::fixed(0, Duration::ZERO, |_| true)
.policy
.max_attempts(),
1
);
assert_eq!(
RetryConfig::fixed(1, Duration::ZERO, |_| true)
.policy
.max_attempts(),
1
);
assert_eq!(
RetryConfig::fixed(5, Duration::ZERO, |_| true)
.policy
.max_attempts(),
5
);
}
}