use std::time::Duration;
use web_time::Instant;
pub(crate) const FIRST_BACKOFF: Duration = Duration::from_millis(50);
pub(crate) const MAX_BACKOFF: Duration = Duration::from_secs(1);
pub(crate) const DEFAULT_RETRY_BUDGET: Duration = Duration::from_secs(10);
pub(crate) const MIN_FAILURES_TO_GIVE_UP: u32 = 12;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum RetryAction {
Configured,
TryNow,
WaitFor(Duration),
GiveUp {
unconfigured_for: Duration,
},
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum RetryLog {
None,
FirstFailure,
RepeatFailure,
Recovered { failures: u32 },
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub(crate) struct ConfigureRetry {
configured: bool,
failures: u32,
first_failure: Option<Instant>,
last_failure: Option<Instant>,
next_attempt: Option<Instant>,
}
impl ConfigureRetry {
pub(crate) fn configured() -> Self {
Self {
configured: true,
..Self::default()
}
}
pub(crate) fn is_configured(&self) -> bool {
self.configured
}
pub(crate) fn failures(&self) -> u32 {
self.failures
}
pub(crate) fn attempt(&self, now: Instant, budget: Duration) -> RetryAction {
if self.configured {
return RetryAction::Configured;
}
if let (Some(first), Some(last)) = (self.first_failure, self.last_failure) {
let unconfigured_for = last.saturating_duration_since(first);
if unconfigured_for > budget && self.failures >= MIN_FAILURES_TO_GIVE_UP {
return RetryAction::GiveUp { unconfigured_for };
}
}
match self.next_attempt {
Some(due) if now < due => RetryAction::WaitFor(due - now),
_ => RetryAction::TryNow,
}
}
pub(crate) fn record(&mut self, ok: bool, now: Instant) -> RetryLog {
if ok {
let failures = self.failures;
*self = Self::configured();
return match failures {
0 => RetryLog::None,
failures => RetryLog::Recovered { failures },
};
}
self.configured = false;
self.failures = self.failures.saturating_add(1);
self.first_failure.get_or_insert(now);
self.last_failure = Some(now);
let delay = backoff_after(self.failures);
self.next_attempt = (!delay.is_zero()).then(|| now + delay);
if self.failures == 1 {
RetryLog::FirstFailure
} else {
RetryLog::RepeatFailure
}
}
}
pub(crate) fn backoff_after(failures: u32) -> Duration {
if failures <= 1 {
return Duration::ZERO;
}
let doublings = (failures - 2).min(16);
FIRST_BACKOFF
.saturating_mul(1u32 << doublings)
.min(MAX_BACKOFF)
}
#[cfg(test)]
mod tests {
use super::*;
const NO_LIMIT: Duration = Duration::MAX;
fn ms(n: u64) -> Duration {
Duration::from_millis(n)
}
fn failed(n: u32, now: Instant) -> ConfigureRetry {
let mut r = ConfigureRetry::default();
for _ in 0..n {
r.record(false, now);
}
r
}
#[test]
fn configured_surface_needs_no_attempt() {
let now = Instant::now();
let mut r = ConfigureRetry::default();
assert_eq!(r.record(true, now), RetryLog::None);
assert!(r.is_configured());
assert_eq!(r.attempt(now, NO_LIMIT), RetryAction::Configured);
assert_eq!(ConfigureRetry::configured(), r);
}
#[test]
fn never_configured_surface_tries_now() {
let r = ConfigureRetry::default();
assert!(!r.is_configured());
assert_eq!(r.attempt(Instant::now(), NO_LIMIT), RetryAction::TryNow);
}
#[test]
fn first_retry_is_immediate() {
let now = Instant::now();
let r = failed(1, now);
assert_eq!(r.attempt(now, NO_LIMIT), RetryAction::TryNow);
}
#[test]
fn backoff_doubles_from_first_backoff_and_caps() {
assert_eq!(backoff_after(0), Duration::ZERO);
assert_eq!(backoff_after(1), Duration::ZERO);
assert_eq!(backoff_after(2), FIRST_BACKOFF);
assert_eq!(backoff_after(3), ms(100));
assert_eq!(backoff_after(4), ms(200));
assert_eq!(backoff_after(5), ms(400));
assert_eq!(backoff_after(6), ms(800));
assert_eq!(backoff_after(7), MAX_BACKOFF);
assert_eq!(backoff_after(8), MAX_BACKOFF);
assert_eq!(backoff_after(u32::MAX), MAX_BACKOFF);
}
#[test]
fn waits_before_the_deadline_and_tries_after_it() {
let t0 = Instant::now();
let r = failed(3, t0); assert_eq!(r.attempt(t0, NO_LIMIT), RetryAction::WaitFor(ms(100)));
assert_eq!(
r.attempt(t0 + ms(40), NO_LIMIT),
RetryAction::WaitFor(ms(60))
);
assert_eq!(r.attempt(t0 + ms(100), NO_LIMIT), RetryAction::TryNow);
assert_eq!(r.attempt(t0 + ms(500), NO_LIMIT), RetryAction::TryNow);
}
#[test]
fn deadline_is_measured_from_the_latest_failure() {
let t0 = Instant::now();
let mut r = failed(1, t0);
let t1 = t0 + ms(16);
r.record(false, t1); assert_eq!(
r.attempt(t1 + ms(10), NO_LIMIT),
RetryAction::WaitFor(ms(40))
);
assert_eq!(r.attempt(t1 + ms(50), NO_LIMIT), RetryAction::TryNow);
}
#[test]
fn first_failure_is_logged_once_then_repeats() {
let now = Instant::now();
let mut r = ConfigureRetry::default();
r.record(true, now);
assert_eq!(r.record(false, now), RetryLog::FirstFailure);
assert_eq!(r.record(false, now), RetryLog::RepeatFailure);
assert_eq!(r.record(false, now), RetryLog::RepeatFailure);
assert_eq!(r.failures(), 3);
assert!(!r.is_configured());
}
#[test]
fn initial_failure_is_a_first_failure() {
let mut r = ConfigureRetry::default();
assert_eq!(r.record(false, Instant::now()), RetryLog::FirstFailure);
}
#[test]
fn recovery_reports_the_failure_count_and_resets() {
let t0 = Instant::now();
let mut r = failed(5, t0);
assert_eq!(r.record(true, t0), RetryLog::Recovered { failures: 5 });
assert!(r.is_configured());
assert_eq!(r.failures(), 0);
assert_eq!(r.attempt(t0, NO_LIMIT), RetryAction::Configured);
assert_eq!(r.record(false, t0), RetryLog::FirstFailure);
assert_eq!(r.attempt(t0, NO_LIMIT), RetryAction::TryNow);
}
fn fail_at_cadence(
r: &mut ConfigureRetry,
mut now: Instant,
budget: Duration,
max_attempts: u32,
) -> (Instant, RetryAction) {
for _ in 0..max_attempts {
match r.attempt(now, budget) {
RetryAction::TryNow => {
r.record(false, now);
}
RetryAction::WaitFor(d) => now += d,
other => return (now, other),
}
}
(now, r.attempt(now, budget))
}
#[test]
fn gives_up_once_a_failure_lands_past_the_budget() {
let budget = Duration::from_secs(10);
let t0 = Instant::now();
let mut r = failed(MIN_FAILURES_TO_GIVE_UP - 1, t0);
r.record(false, t0 + budget);
assert!(matches!(
r.attempt(t0 + budget, budget),
RetryAction::TryNow | RetryAction::WaitFor(_)
));
let late = t0 + budget + ms(1);
r.record(false, late);
assert_eq!(
r.attempt(late, budget),
RetryAction::GiveUp {
unconfigured_for: budget + ms(1)
}
);
assert!(matches!(
r.attempt(late + budget, budget),
RetryAction::GiveUp { .. }
));
}
#[test]
fn failure_count_alone_does_not_give_up() {
let budget = Duration::from_secs(10);
let t0 = Instant::now();
let mut r = failed(MIN_FAILURES_TO_GIVE_UP * 3, t0);
r.record(false, t0 + budget / 2);
assert!(!matches!(
r.attempt(t0 + budget / 2, budget),
RetryAction::GiveUp { .. }
));
}
#[test]
fn minimized_window_is_not_given_up_on_its_first_retry_after_restore() {
let budget = Duration::from_secs(10);
let t0 = Instant::now();
let mut r = failed(2, t0);
let restored = t0 + budget * 6;
assert_eq!(r.attempt(restored, budget), RetryAction::TryNow);
r.record(false, restored);
assert!(
!matches!(r.attempt(restored, budget), RetryAction::GiveUp { .. }),
"one failed retry after a long idle must not give up"
);
let (gave_up_at, action) = fail_at_cadence(&mut r, restored, budget, 1000);
assert!(
matches!(action, RetryAction::GiveUp { .. }),
"a surface that keeps failing is eventually given up on: {action:?}"
);
assert_eq!(r.failures(), MIN_FAILURES_TO_GIVE_UP);
assert!(gave_up_at - restored < budget);
}
#[test]
fn continuous_retrying_gives_up_at_about_the_budget() {
let budget = DEFAULT_RETRY_BUDGET;
let t0 = Instant::now();
let mut r = failed(1, t0);
let (gave_up_at, action) = fail_at_cadence(&mut r, t0, budget, 1000);
assert!(matches!(action, RetryAction::GiveUp { .. }), "{action:?}");
let elapsed = gave_up_at - t0;
assert!(
elapsed > budget && elapsed <= budget + MAX_BACKOFF,
"gave up after {elapsed:?}"
);
assert!(r.failures() >= MIN_FAILURES_TO_GIVE_UP);
}
#[test]
fn budget_needs_a_failed_attempt_not_just_elapsed_time() {
let budget = Duration::from_secs(10);
let t0 = Instant::now();
let r = failed(MIN_FAILURES_TO_GIVE_UP, t0);
assert_eq!(
r.attempt(t0 + budget * 6, budget),
RetryAction::TryNow,
"elapsed time alone must not give up"
);
}
#[test]
fn budget_is_measured_from_the_first_failure_of_the_run() {
let budget = Duration::from_secs(10);
let t0 = Instant::now();
let mut r = failed(3, t0);
r.record(true, t0 + ms(500));
let t1 = t0 + Duration::from_secs(9);
for _ in 0..MIN_FAILURES_TO_GIVE_UP {
r.record(false, t1);
}
r.record(false, t1 + Duration::from_secs(9));
assert!(
!matches!(
r.attempt(t1 + Duration::from_secs(9), budget),
RetryAction::GiveUp { .. }
),
"18 s after the first ever failure, but only 9 s into this run"
);
}
#[test]
fn success_after_giving_up_resets_everything() {
let budget = Duration::from_secs(1);
let t0 = Instant::now();
let mut r = failed(MIN_FAILURES_TO_GIVE_UP, t0);
r.record(false, t0 + Duration::from_secs(2));
assert!(matches!(
r.attempt(t0 + Duration::from_secs(2), budget),
RetryAction::GiveUp { .. }
));
let t1 = t0 + Duration::from_secs(3);
assert_eq!(
r.record(true, t1),
RetryLog::Recovered {
failures: MIN_FAILURES_TO_GIVE_UP + 1
}
);
assert_eq!(r.attempt(t1, budget), RetryAction::Configured);
}
#[test]
fn max_budget_never_gives_up() {
let t0 = Instant::now();
let mut r = failed(1, t0);
let late = t0 + Duration::from_secs(60 * 60 * 24);
r.record(false, late);
assert!(!matches!(
r.attempt(late, NO_LIMIT),
RetryAction::GiveUp { .. }
));
}
}