use std::time::Duration;
use rand::RngExt as _;
pub const DEFAULT_MIN_BACKOFF: Duration = Duration::from_secs(1);
pub const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(30);
#[cfg(feature = "_ws")]
pub(crate) fn check_window(min: Duration, max: Duration) -> crate::error::Result<()> {
if min.is_zero() {
return Err(crate::Error::InvalidRequest(
"min_backoff must be a positive duration; zero reconnects \
continuously rather than immediately"
.to_owned(),
));
}
if max < min {
return Err(crate::Error::InvalidRequest(
"max_backoff must be at least min_backoff".to_owned(),
));
}
Ok(())
}
#[cfg(feature = "_ws")]
pub(crate) fn check_data_timeout(timeout: Duration) -> crate::error::Result<()> {
if timeout.is_zero() {
return Err(crate::Error::InvalidRequest(
"data_timeout must be a positive duration".to_owned(),
));
}
Ok(())
}
#[cfg(feature = "_ws")]
pub(crate) fn check_stable_session(after: Duration) -> crate::error::Result<()> {
if after.is_zero() {
return Err(crate::Error::InvalidRequest(
"stable_session must be a positive duration".to_owned(),
));
}
Ok(())
}
#[must_use]
pub fn reconnect_delay(retries: u32, min_backoff: Duration, max_backoff: Duration) -> Duration {
let capped = capped_delay(retries, min_backoff, max_backoff).as_secs_f64();
let half = capped / 2.0;
let jitter = if half > 0.0 {
rand::rng().random_range(0.0..half)
} else {
0.0
};
seconds_to_duration(half + jitter, max_backoff)
}
fn seconds_to_duration(seconds: f64, ceiling: Duration) -> Duration {
Duration::try_from_secs_f64(seconds)
.unwrap_or(ceiling)
.min(ceiling)
}
#[must_use]
pub fn capped_delay(retries: u32, min_backoff: Duration, max_backoff: Duration) -> Duration {
let max = max_backoff.as_secs_f64();
let mut capped = min_backoff.as_secs_f64();
for _ in 0..retries.saturating_sub(1) {
if capped >= max / 2.0 {
capped = max;
break;
}
capped *= 2.0;
}
seconds_to_duration(capped.min(max), max_backoff)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_unbounded_ceiling_saturates_instead_of_panicking() {
assert_eq!(
capped_delay(65, Duration::from_secs(1), Duration::MAX),
Duration::MAX
);
let delay = reconnect_delay(65, Duration::from_secs(1), Duration::MAX);
assert!(delay >= Duration::MAX / 2);
}
#[test]
fn a_ceiling_near_the_representable_limit_is_still_a_duration() {
let huge = Duration::from_secs(u64::MAX / 2);
assert_eq!(capped_delay(100, Duration::from_secs(1), huge), huge);
assert!(reconnect_delay(100, Duration::from_secs(1), huge) >= huge / 2);
}
const MIN: Duration = DEFAULT_MIN_BACKOFF;
const MAX: Duration = DEFAULT_MAX_BACKOFF;
#[test]
fn growth_curve_doubles_to_the_cap() {
let expected = [1.0, 1.0, 2.0, 4.0, 8.0, 16.0, 30.0, 30.0, 30.0];
for (retries, want) in expected.into_iter().enumerate() {
#[allow(clippy::cast_possible_truncation)]
let got = capped_delay(retries as u32, MIN, MAX).as_secs_f64();
assert!(
(got - want).abs() < f64::EPSILON,
"retries={retries}: got {got}, want {want}"
);
}
}
#[test]
fn jitter_stays_within_half_to_full_window() {
for retries in 1..=8 {
let capped = capped_delay(retries, MIN, MAX);
for _ in 0..64 {
let delay = reconnect_delay(retries, MIN, MAX);
assert!(
delay >= capped / 2 && delay <= capped,
"retries={retries}: {delay:?} outside [{:?}, {capped:?}]",
capped / 2
);
}
}
}
#[test]
fn jitter_actually_varies() {
let samples: Vec<_> = (0..32).map(|_| reconnect_delay(5, MIN, MAX)).collect();
let first = samples[0];
assert!(
samples.iter().any(|d| *d != first),
"delay is constant, jitter is not being applied"
);
}
#[test]
fn never_exceeds_the_ceiling() {
assert_eq!(capped_delay(1_000, MIN, MAX), MAX);
}
}