use std::time::Duration;
use tracing::debug;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct BackoffLimits {
pub(crate) initial: Duration,
pub(crate) max_delay: Duration,
pub(crate) max_attempts: u32,
}
impl BackoffLimits {
pub(crate) const ACCOUNT_STREAM: Self = Self {
initial: Duration::from_secs(1),
max_delay: Duration::from_secs(30),
max_attempts: 10,
};
}
pub(crate) struct ReconnectBudget {
venue: &'static str,
limits: BackoffLimits,
attempt: u32,
}
impl ReconnectBudget {
pub(crate) fn new(venue: &'static str, limits: BackoffLimits) -> Self {
Self {
venue,
limits,
attempt: 0,
}
}
pub(crate) fn reset(&mut self) {
self.attempt = 0;
}
pub(crate) fn attempts(&self) -> u32 {
self.attempt
}
fn next_delay(&mut self) -> Option<Duration> {
if self.attempt >= self.limits.max_attempts {
return None;
}
let delay = self
.limits
.initial
.saturating_mul(2u32.saturating_pow(self.attempt))
.min(self.limits.max_delay);
self.attempt += 1;
Some(delay)
}
pub(crate) async fn wait(&mut self) -> bool {
let Some(delay) = self.next_delay() else {
return false;
};
debug!(
attempt = self.attempt,
max = self.limits.max_attempts,
delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX),
"{} reconnect backoff",
self.venue
);
tokio::time::sleep(delay).await;
true
}
}
#[cfg(test)]
mod tests {
use super::*;
const fn ms(ms: u64) -> Duration {
Duration::from_millis(ms)
}
fn delays(budget: &mut ReconnectBudget) -> Vec<Duration> {
std::iter::from_fn(|| budget.next_delay())
.take(100)
.collect()
}
#[test]
fn the_account_stream_budget_is_as_documented() {
assert_eq!(
BackoffLimits::ACCOUNT_STREAM,
BackoffLimits {
initial: Duration::from_secs(1),
max_delay: Duration::from_secs(30),
max_attempts: 10,
}
);
}
#[test]
fn the_account_stream_waits_double_up_to_the_cap() {
let mut budget = ReconnectBudget::new("Test", BackoffLimits::ACCOUNT_STREAM);
let secs = [1, 2, 4, 8, 16, 30, 30, 30, 30, 30].map(Duration::from_secs);
assert_eq!(delays(&mut budget), secs);
assert_eq!(budget.attempts(), 10);
}
#[test]
fn the_waits_follow_the_limits_given() {
let limits = BackoffLimits {
initial: ms(3),
max_delay: ms(10),
max_attempts: 4,
};
let mut budget = ReconnectBudget::new("Test", limits);
assert_eq!(delays(&mut budget), [ms(3), ms(6), ms(10), ms(10)]);
}
#[test]
fn a_spent_budget_stays_spent_until_reset() {
let limits = BackoffLimits {
initial: ms(1),
max_delay: ms(4),
max_attempts: 2,
};
let mut budget = ReconnectBudget::new("Test", limits);
assert_eq!(delays(&mut budget), [ms(1), ms(2)]);
assert_eq!(budget.next_delay(), None);
assert_eq!(budget.attempts(), 2);
budget.reset();
assert_eq!(budget.attempts(), 0);
assert_eq!(delays(&mut budget), [ms(1), ms(2)]);
}
#[tokio::test(start_paused = true)]
async fn wait_sleeps_each_delay_and_ends_when_spent() {
let limits = BackoffLimits {
initial: ms(1),
max_delay: ms(4),
max_attempts: 2,
};
let mut budget = ReconnectBudget::new("Test", limits);
let started = tokio::time::Instant::now();
assert!(budget.wait().await);
assert_eq!(started.elapsed(), ms(1));
assert!(budget.wait().await);
assert_eq!(started.elapsed(), ms(3));
assert!(!budget.wait().await);
assert_eq!(started.elapsed(), ms(3));
assert_eq!(budget.attempts(), 2);
}
}