use core::future::Future;
use core::task::{Context, Poll, Waker};
use core::time::Duration;
use reliakit_retry::{retry, retry_async, retry_with_sleep, Backoff, RetryError, RetryPolicy};
fn block_on<F: Future>(future: F) -> F::Output {
let waker = Waker::noop();
let mut cx = Context::from_waker(waker);
let mut future = core::pin::pin!(future);
loop {
if let Poll::Ready(value) = future.as_mut().poll(&mut cx) {
return value;
}
}
}
fn policy(max_attempts: u32) -> RetryPolicy {
RetryPolicy::new(max_attempts, Backoff::constant(Duration::from_millis(5))).unwrap()
}
#[test]
fn succeeds_on_first_attempt() {
let mut calls = 0;
let result: Result<u32, RetryError<&str>> = retry(
&policy(3),
|| {
calls += 1;
Ok(7)
},
|_| true,
);
assert_eq!(result.unwrap(), 7);
assert_eq!(calls, 1, "must not retry after success");
}
#[test]
fn succeeds_after_retries() {
let mut calls = 0;
let result: Result<u32, RetryError<&str>> = retry(
&policy(5),
|| {
calls += 1;
if calls < 3 {
Err("temporary")
} else {
Ok(99)
}
},
|_| true,
);
assert_eq!(result.unwrap(), 99);
assert_eq!(calls, 3);
}
#[test]
fn exhausts_max_attempts() {
let mut calls = 0;
let result: Result<u32, RetryError<&str>> = retry(
&policy(4),
|| {
calls += 1;
Err("always")
},
|_| true,
);
match result {
Err(RetryError::Exhausted {
attempts,
last_error,
}) => {
assert_eq!(attempts, 4);
assert_eq!(last_error, "always");
}
other => panic!("expected Exhausted, got {other:?}"),
}
assert_eq!(calls, 4);
}
#[test]
fn should_retry_controls_continuation() {
let mut calls = 0;
let result: Result<u32, RetryError<&str>> = retry(
&policy(5),
|| {
calls += 1;
Err("fatal")
},
|error| *error != "fatal",
);
assert!(matches!(
result,
Err(RetryError::Exhausted { attempts: 1, .. })
));
assert_eq!(calls, 1, "fatal error must not be retried");
let mut calls = 0;
let result: Result<u32, RetryError<&str>> = retry(
&policy(5),
|| {
calls += 1;
if calls < 4 {
Err("transient")
} else {
Ok(1)
}
},
|error| *error == "transient",
);
assert_eq!(result.unwrap(), 1);
assert_eq!(calls, 4);
}
#[test]
fn backoff_delays_are_passed_to_sleeper() {
let backoff = Backoff::exponential(Duration::from_millis(1), 2);
let policy = RetryPolicy::new(4, backoff).unwrap();
let mut waited: Vec<Duration> = Vec::new();
let result: Result<(), RetryError<u8>> =
retry_with_sleep(&policy, || Err(1u8), |_| true, |delay| waited.push(delay));
assert!(matches!(
result,
Err(RetryError::Exhausted { attempts: 4, .. })
));
assert_eq!(
waited,
[
backoff.delay(0).unwrap(),
backoff.delay(1).unwrap(),
backoff.delay(2).unwrap(),
]
);
assert_eq!(
waited,
[
Duration::from_millis(1),
Duration::from_millis(2),
Duration::from_millis(4),
]
);
}
#[test]
fn plain_retry_needs_no_sleeper() {
let mut calls = 0;
let result: Result<(), RetryError<()>> = retry(
&policy(3),
|| {
calls += 1;
Err(())
},
|_| true,
);
assert!(matches!(
result,
Err(RetryError::Exhausted { attempts: 3, .. })
));
assert_eq!(calls, 3);
}
#[test]
fn zero_attempts_rejected_and_single_tries_once() {
assert!(RetryPolicy::new(0, Backoff::constant(Duration::ZERO)).is_none());
let policy = RetryPolicy::single(Backoff::constant(Duration::from_secs(99)));
assert_eq!(policy.max_attempts(), 1);
let mut calls = 0;
let result: Result<u32, RetryError<&str>> = retry(
&policy,
|| {
calls += 1;
Err("nope")
},
|_| true, );
assert!(matches!(
result,
Err(RetryError::Exhausted { attempts: 1, .. })
));
assert_eq!(calls, 1, "single() must try exactly once");
}
#[test]
fn async_retry_drives_with_fake_sleep() {
let mut waited: Vec<Duration> = Vec::new();
let mut calls = 0;
let result: Result<u32, RetryError<&str>> = block_on(retry_async(
&policy(5),
|| {
calls += 1;
let outcome = if calls < 3 { Err("temporary") } else { Ok(55) };
core::future::ready(outcome)
},
|_| true,
|delay| {
waited.push(delay);
core::future::ready(())
},
));
assert_eq!(result.unwrap(), 55);
assert_eq!(calls, 3);
assert_eq!(waited, [Duration::from_millis(5), Duration::from_millis(5)]);
}
#[test]
fn integrates_with_backoff_constructors() {
for backoff in [
Backoff::constant(Duration::from_millis(2)),
Backoff::linear(Duration::from_millis(1), Duration::from_millis(3)),
Backoff::exponential(Duration::from_millis(1), 3).with_max_delay(Duration::from_millis(10)),
] {
let policy = RetryPolicy::new(3, backoff).unwrap();
let result: Result<u32, RetryError<&str>> =
retry_with_sleep(&policy, || Ok(0), |_| true, |_| {});
assert_eq!(result.unwrap(), 0);
assert_eq!(
policy.delay_before_retry(1),
backoff.delay(0).unwrap_or(Duration::ZERO)
);
}
}
#[test]
fn retry_error_accessors() {
let err: RetryError<&str> = RetryError::Exhausted {
attempts: 2,
last_error: "boom",
};
assert_eq!(err.attempts(), 2);
assert_eq!(*err.last_error(), "boom");
assert_eq!(err.into_last_error(), "boom");
}
#[test]
fn retry_error_display_and_source() {
use std::error::Error;
#[derive(Debug)]
struct Inner;
impl core::fmt::Display for Inner {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str("inner failure")
}
}
impl Error for Inner {}
let err: RetryError<Inner> = RetryError::Exhausted {
attempts: 3,
last_error: Inner,
};
let text = format!("{err}");
assert!(
text.contains('3'),
"message names the attempt count: {text}"
);
assert!(
text.contains("inner failure"),
"message includes the cause: {text}"
);
assert!(
err.source().is_some(),
"the cause is exposed as the error source"
);
}
#[test]
fn policy_accessors() {
let backoff = Backoff::constant(Duration::from_millis(7));
let policy = RetryPolicy::new(3, backoff).unwrap();
assert_eq!(policy.max_attempts(), 3);
assert_eq!(policy.backoff().delay(0), backoff.delay(0));
assert_eq!(policy.delay_before_retry(1), Duration::from_millis(7));
}
#[test]
fn async_retry_exhausts() {
let mut calls = 0;
let mut sleeps = 0;
let result: Result<u32, RetryError<&str>> = block_on(retry_async(
&policy(3),
|| {
calls += 1;
core::future::ready(Err("nope"))
},
|_| true,
|_delay| {
sleeps += 1;
core::future::ready(())
},
));
assert!(matches!(
result,
Err(RetryError::Exhausted { attempts: 3, .. })
));
assert_eq!(calls, 3);
assert_eq!(sleeps, 2, "slept before retries 2 and 3");
}