use std::future::Future;
use std::time::Duration;
use crate::error::{BrainError, Result};
fn jittered(delay: Duration, floor: Duration) -> Duration {
let floor = floor.min(delay);
let span = delay.saturating_sub(floor);
let span_nanos = u64::try_from(span.as_nanos()).unwrap_or(u64::MAX);
if span_nanos == 0 {
return floor;
}
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0x9E37_79B9_7F4A_7C15, |d| {
u64::try_from(d.as_nanos() & u128::from(u64::MAX)).unwrap_or(0)
});
let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
let extra = z % span_nanos;
floor + Duration::from_nanos(extra)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RetryStep {
Retry(Option<Duration>),
GiveUp,
}
#[derive(Clone, Debug)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub base_delay: Duration,
pub max_delay: Duration,
}
impl RetryPolicy {
#[must_use]
pub fn none() -> Self {
Self {
max_attempts: 1,
base_delay: Duration::ZERO,
max_delay: Duration::ZERO,
}
}
#[must_use]
pub fn new(max_attempts: u32, base_delay: Duration, max_delay: Duration) -> Self {
Self {
max_attempts: max_attempts.max(1),
base_delay,
max_delay,
}
}
#[must_use]
pub fn backoff(&self, attempt: u32, server_retry_after: Option<Duration>) -> Option<Duration> {
if let Some(ra) = server_retry_after {
return Some(ra.min(self.max_delay));
}
if self.base_delay.is_zero() {
return None;
}
let shift = attempt.saturating_sub(1).min(31);
let mult = 1u32 << shift;
let delay = self
.base_delay
.checked_mul(mult)
.unwrap_or(self.max_delay)
.min(self.max_delay);
Some(delay)
}
#[must_use]
pub fn step(&self, attempt: u32, err: &BrainError) -> RetryStep {
if attempt < self.max_attempts && err.is_retryable() {
let server_floor = err.retry_after();
let delay = self.backoff(attempt, server_floor).map(|delay| {
jittered(
delay,
server_floor.unwrap_or(Duration::ZERO).min(self.max_delay),
)
});
RetryStep::Retry(delay)
} else {
RetryStep::GiveUp
}
}
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_attempts: 3,
base_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(5),
}
}
}
pub async fn with_retry<T, F, Fut>(policy: &RetryPolicy, mut op: F) -> Result<T>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T>>,
{
let mut attempt: u32 = 1;
loop {
match op().await {
Ok(value) => return Ok(value),
Err(err) => match policy.step(attempt, &err) {
RetryStep::Retry(delay) => {
if let Some(delay) = delay {
tokio::time::sleep(delay).await;
}
attempt += 1;
}
RetryStep::GiveUp => return Err(err),
},
}
}
}
pub async fn with_default_retry<T, F, Fut>(op: F) -> Result<T>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T>>,
{
with_retry(&RetryPolicy::default(), op).await
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
#[test]
fn exponential_backoff_doubles_and_caps() {
let p = RetryPolicy::new(5, Duration::from_millis(100), Duration::from_secs(1));
assert_eq!(p.backoff(1, None), Some(Duration::from_millis(100)));
assert_eq!(p.backoff(2, None), Some(Duration::from_millis(200)));
assert_eq!(p.backoff(3, None), Some(Duration::from_millis(400)));
assert_eq!(p.backoff(4, None), Some(Duration::from_millis(800)));
assert_eq!(p.backoff(5, None), Some(Duration::from_secs(1)));
}
#[test]
fn server_retry_after_overrides_and_is_capped() {
let p = RetryPolicy::new(3, Duration::from_millis(100), Duration::from_secs(2));
assert_eq!(
p.backoff(1, Some(Duration::from_millis(500))),
Some(Duration::from_millis(500))
);
assert_eq!(
p.backoff(1, Some(Duration::from_secs(99))),
Some(Duration::from_secs(2)),
"server hint is capped at max_delay"
);
}
#[test]
fn jitter_stays_within_bounds_and_honors_the_floor() {
let delay = Duration::from_millis(800);
for _ in 0..1000 {
let j = jittered(delay, Duration::ZERO);
assert!(j <= delay, "jitter must not exceed the base delay");
}
let floor = Duration::from_millis(200);
for _ in 0..1000 {
let j = jittered(delay, floor);
assert!(j >= floor, "jitter must never wake before the server floor");
assert!(j <= delay, "jitter must not exceed the base delay");
}
}
#[test]
fn jitter_with_zero_span_returns_the_floor() {
let d = Duration::from_millis(500);
assert_eq!(jittered(d, d), d);
assert_eq!(jittered(d, Duration::from_secs(99)), d);
}
#[test]
fn step_jitters_within_the_scheduled_delay() {
let p = RetryPolicy::new(5, Duration::from_millis(100), Duration::from_secs(1));
for _ in 0..1000 {
match p.step(4, &BrainError::Closed) {
RetryStep::Retry(Some(d)) => assert!(d <= Duration::from_millis(800)),
other => panic!("expected a jittered retry, got {other:?}"),
}
}
}
#[test]
fn none_policy_means_no_backoff() {
let p = RetryPolicy::none();
assert_eq!(p.max_attempts, 1);
assert_eq!(p.backoff(1, None), None);
}
#[tokio::test]
async fn retries_until_success_then_stops() {
let calls = Cell::new(0u32);
let policy = RetryPolicy::new(5, Duration::ZERO, Duration::ZERO);
let out: Result<u32> = with_retry(&policy, || {
let n = calls.get() + 1;
calls.set(n);
async move {
if n < 3 {
Err(BrainError::Closed)
} else {
Ok(n)
}
}
})
.await;
assert_eq!(out.unwrap(), 3);
assert_eq!(calls.get(), 3);
}
#[tokio::test]
async fn does_not_retry_a_non_retryable_error() {
let calls = Cell::new(0u32);
let policy = RetryPolicy::new(5, Duration::ZERO, Duration::ZERO);
let out: Result<u32> = with_retry(&policy, || {
calls.set(calls.get() + 1);
async move { Err(BrainError::Protocol("nope".into())) }
})
.await;
assert!(matches!(out, Err(BrainError::Protocol(_))));
assert_eq!(calls.get(), 1, "a non-retryable error must not retry");
}
#[tokio::test]
async fn gives_up_after_max_attempts() {
let calls = Cell::new(0u32);
let policy = RetryPolicy::new(3, Duration::ZERO, Duration::ZERO);
let out: Result<u32> = with_retry(&policy, || {
calls.set(calls.get() + 1);
async move { Err(BrainError::Closed) }
})
.await;
assert!(matches!(out, Err(BrainError::Closed)));
assert_eq!(calls.get(), 3, "exactly max_attempts tries");
}
}