use std::future::ready;
use std::num::NonZeroU32;
use std::ops::ControlFlow;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use atuin_common::futures::Backoff;
use tokio::time::Instant;
const SEC: Duration = Duration::from_secs(1);
#[track_caller]
fn assert_in_band(actual: Duration, expected: Duration, label: &str) {
let lo = expected.mul_f64(0.89);
let hi = expected.mul_f64(1.11);
assert!(
actual >= lo && actual <= hi,
"{label}: {actual:?} is outside the +/-10% jitter band of {expected:?} ({lo:?}..={hi:?})"
);
}
fn gaps(stamps: &[Duration]) -> Vec<Duration> {
stamps.windows(2).map(|w| w[1] - w[0]).collect()
}
async fn record(backoff: Backoff, fails: usize, timeout: Duration) -> Vec<Duration> {
let start = Instant::now();
let mut stamps = Vec::new();
let _: Result<(), ()> = backoff
.retry_sync(
|| {
stamps.push(start.elapsed());
if stamps.len() > fails {
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
},
timeout,
)
.await;
stamps
}
#[tokio::test(start_paused = true)]
async fn eager_first_call_can_exceed_the_timeout() {
let first_call_cost = Duration::from_secs(100);
let timeout = Duration::from_millis(1);
let calls = AtomicUsize::new(0);
let start = Instant::now();
let result: Result<(), ()> = Backoff::Linear(Duration::from_secs(10))
.retry(
|| async {
calls.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(first_call_cost).await;
ControlFlow::Continue(())
},
timeout,
)
.await;
assert!(result.is_err(), "a never-succeeding episode must time out");
assert_eq!(calls.load(Ordering::SeqCst), 1, "only the eager attempt ran before the timeout");
assert!(
start.elapsed() >= first_call_cost,
"episode returned in {:?}, but the eager first call alone costs {first_call_cost:?} and \
is outside the {timeout:?} budget",
start.elapsed()
);
}
#[tokio::test(start_paused = true)]
async fn exponential_backoff_grows_geometrically() {
let backoff = Backoff::Exponential {
initial: SEC,
max: Duration::from_secs(1000),
factor: NonZeroU32::new(2).unwrap(),
};
let stamps = record(backoff, 5, Duration::from_secs(10_000)).await;
assert_eq!(stamps.len(), 6, "expected five failures then a break");
assert_eq!(stamps[0], Duration::ZERO, "the first attempt must fire eagerly, with no delay");
let observed = gaps(&stamps);
let expected = [SEC, 2 * SEC, 4 * SEC, 8 * SEC, 16 * SEC];
for (i, (got, want)) in observed.iter().zip(expected).enumerate() {
assert_in_band(*got, want, &format!("backoff #{}", i + 1));
}
}
#[tokio::test(start_paused = true)]
async fn exponential_backoff_never_exceeds_max() {
let max = 5 * SEC;
let backoff = Backoff::Exponential {
initial: SEC,
max,
factor: NonZeroU32::new(10).unwrap(),
};
let stamps = record(backoff, 7, Duration::from_secs(10_000)).await;
let observed = gaps(&stamps);
for (i, got) in observed.iter().enumerate() {
assert!(*got <= max, "backoff #{} was {got:?}, above the {max:?} ceiling", i + 1);
}
for (i, got) in observed.iter().enumerate().skip(1) {
assert_in_band(*got, max, &format!("saturated backoff #{}", i + 1));
}
}
#[tokio::test(start_paused = true)]
async fn exponential_initial_is_capped_to_max() {
let max = 10 * SEC;
let backoff = Backoff::Exponential {
initial: 100 * SEC, max,
factor: NonZeroU32::new(2).unwrap(),
};
let stamps = record(backoff, 1, Duration::from_secs(10_000)).await;
let observed = gaps(&stamps);
assert_eq!(observed.len(), 1, "expected one failure then a break");
assert_in_band(observed[0], max, "first delay when initial > max");
}
#[tokio::test(start_paused = true)]
async fn linear_zero_spins_with_no_delay() {
let calls = AtomicUsize::new(0);
let start = Instant::now();
let result: Result<usize, ()> = Backoff::Linear(Duration::ZERO)
.retry_sync(
|| {
let n = calls.fetch_add(1, Ordering::SeqCst) + 1;
if n >= 500 {
ControlFlow::Break(n)
} else {
ControlFlow::Continue(())
}
},
Duration::from_secs(10_000),
)
.await;
assert_eq!(result, Ok(500), "spinning must still honor Break and thread its value");
assert_eq!(calls.load(Ordering::SeqCst), 500);
assert_eq!(start.elapsed(), Duration::ZERO, "a ZERO linear backoff must insert no delay");
}
#[tokio::test(start_paused = true)]
async fn linear_waits_its_period_between_attempts() {
let period = 2 * SEC;
let stamps = record(Backoff::Linear(period), 5, Duration::from_secs(10_000)).await;
assert_eq!(stamps.len(), 6, "expected five failures then a break");
assert_eq!(stamps[0], Duration::ZERO, "only the eager first attempt is un-delayed");
for (i, got) in gaps(&stamps).iter().enumerate() {
assert_in_band(*got, period, &format!("linear gap #{}", i + 1));
}
}
#[tokio::test(start_paused = true)]
async fn timeout_mid_backoff_returns_the_last_continue_reason() {
let calls = AtomicUsize::new(0);
let result: Result<(), usize> = Backoff::Linear(10 * SEC)
.retry_sync(|| ControlFlow::Continue(calls.fetch_add(1, Ordering::SeqCst)), 25 * SEC)
.await;
assert_eq!(result, Err(2), "timeout must surface the most recent Continue reason");
assert_eq!(calls.load(Ordering::SeqCst), 3, "exactly three attempts fit inside the timeout");
}
#[tokio::test(start_paused = true)]
async fn break_before_timeout_returns_the_ok_value() {
let calls = AtomicUsize::new(0);
let result: Result<&str, usize> = Backoff::Linear(10 * SEC)
.retry_sync(
|| {
let n = calls.fetch_add(1, Ordering::SeqCst);
if n >= 2 {
ControlFlow::Break("done")
} else {
ControlFlow::Continue(n)
}
},
25 * SEC,
)
.await;
assert_eq!(result, Ok("done"), "a break inside the budget must return Ok(value), not time out");
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[tokio::test(start_paused = true)]
async fn retry_forever_backs_off_until_break() {
let backoff = Backoff::Exponential {
initial: SEC,
max: Duration::from_secs(1000),
factor: NonZeroU32::new(2).unwrap(),
};
let start = Instant::now();
let mut stamps = Vec::new();
let out: &str = backoff
.retry_forever(|| {
stamps.push(start.elapsed());
ready(if stamps.len() > 5 {
ControlFlow::Break("done")
} else {
ControlFlow::Continue(())
})
})
.await;
assert_eq!(out, "done", "retry_forever returns the Break value directly");
assert_eq!(stamps.len(), 6, "five failures then a break");
assert_eq!(stamps[0], Duration::ZERO, "the first attempt fires eagerly");
let expected = [SEC, 2 * SEC, 4 * SEC, 8 * SEC, 16 * SEC];
for (i, (got, want)) in gaps(&stamps).iter().zip(expected).enumerate() {
assert_in_band(*got, want, &format!("retry_forever backoff #{}", i + 1));
}
}
#[tokio::test(start_paused = true)]
async fn retry_forever_saturates_at_max_and_never_gives_up() {
let max = 5 * SEC;
let backoff = Backoff::Exponential {
initial: SEC,
max,
factor: NonZeroU32::new(10).unwrap(), };
let fails = 100usize;
let start = Instant::now();
let mut stamps = Vec::new();
let out: usize = backoff
.retry_forever(|| {
stamps.push(start.elapsed());
ready(if stamps.len() > fails {
ControlFlow::Break(stamps.len())
} else {
ControlFlow::Continue(())
})
})
.await;
assert_eq!(out, fails + 1, "it kept retrying through every failure until Break");
assert_eq!(stamps.len(), fails + 1);
for (i, got) in gaps(&stamps).iter().enumerate().skip(1) {
assert_in_band(*got, max, &format!("saturated retry_forever gap #{}", i + 1));
}
assert!(
start.elapsed() > 50 * max,
"retry_forever gave up early: only {:?} elapsed across {fails} failures",
start.elapsed()
);
}
#[tokio::test(start_paused = true)]
async fn retry_sync_matches_retry() {
let backoff = Backoff::Exponential {
initial: 2 * SEC,
max: Duration::from_secs(1000),
factor: NonZeroU32::new(3).unwrap(),
};
let timeout = Duration::from_secs(10_000);
let expected = [2 * SEC, 6 * SEC];
let sync_start = Instant::now();
let mut sync_stamps = Vec::new();
let sync_result: Result<&str, ()> = backoff
.retry_sync(
|| {
sync_stamps.push(sync_start.elapsed());
if sync_stamps.len() > 2 {
ControlFlow::Break("ok")
} else {
ControlFlow::Continue(())
}
},
timeout,
)
.await;
let async_start = Instant::now();
let mut async_stamps = Vec::new();
let async_result: Result<&str, ()> = backoff
.retry(
|| {
async_stamps.push(async_start.elapsed());
ready(if async_stamps.len() > 2 {
ControlFlow::Break("ok")
} else {
ControlFlow::Continue(())
})
},
timeout,
)
.await;
assert_eq!(sync_result, Ok("ok"));
assert_eq!(async_result, sync_result, "retry_sync and retry must return the same value");
for (path, stamps) in [("sync", &sync_stamps), ("async", &async_stamps)] {
assert_eq!(stamps.len(), 3, "{path}: two failures then a break");
assert_eq!(stamps[0], Duration::ZERO, "{path}: eager first attempt");
for (i, (got, want)) in gaps(stamps).iter().zip(expected).enumerate() {
assert_in_band(*got, want, &format!("{path} backoff #{}", i + 1));
}
}
}