use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum StopReason {
RetriesExhausted,
NotRetryable,
MaxElapsed,
}
impl StopReason {
pub const fn as_str(self) -> &'static str {
match self {
Self::RetriesExhausted => "retries_exhausted",
Self::NotRetryable => "not_retryable",
Self::MaxElapsed => "max_elapsed",
}
}
}
impl std::fmt::Display for StopReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let prose = match self {
Self::RetriesExhausted => "retries exhausted",
Self::NotRetryable => "error was not retryable",
Self::MaxElapsed => "time budget spent",
};
f.write_str(prose)
}
}
#[derive(Debug, Clone)]
pub struct RetryError<E> {
error: E,
attempts: u32,
elapsed: Duration,
stop_reason: StopReason,
}
impl<E> RetryError<E> {
pub(crate) fn new(error: E, attempts: u32, elapsed: Duration, stop_reason: StopReason) -> Self {
Self {
error,
attempts,
elapsed,
stop_reason,
}
}
pub fn error(&self) -> &E {
&self.error
}
pub fn into_error(self) -> E {
self.error
}
pub fn attempts(&self) -> u32 {
self.attempts
}
pub fn elapsed(&self) -> Duration {
self.elapsed
}
pub fn stop_reason(&self) -> StopReason {
self.stop_reason
}
}
impl<E: std::fmt::Display> std::fmt::Display for RetryError<E> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"gave up after {} attempt{} in {:?} ({}): {}",
self.attempts,
if self.attempts == 1 { "" } else { "s" },
self.elapsed,
self.stop_reason,
self.error
)
}
}
impl<E: std::fmt::Debug + std::fmt::Display> std::error::Error for RetryError<E> {}
#[cfg(test)]
mod tests {
use super::*;
fn err() -> RetryError<&'static str> {
RetryError::new(
"connection refused",
4,
Duration::from_millis(7150),
StopReason::RetriesExhausted,
)
}
#[test]
fn display_carries_the_inner_error() {
assert_eq!(
err().to_string(),
"gave up after 4 attempts in 7.15s (retries exhausted): connection refused"
);
}
#[test]
fn display_does_not_say_one_attempts() {
let e = RetryError::new("nope", 1, Duration::ZERO, StopReason::NotRetryable);
assert!(e.to_string().contains("1 attempt in"), "{e}");
}
#[test]
fn stop_reason_tokens_are_stable() {
assert_eq!(StopReason::RetriesExhausted.as_str(), "retries_exhausted");
assert_eq!(StopReason::NotRetryable.as_str(), "not_retryable");
assert_eq!(StopReason::MaxElapsed.as_str(), "max_elapsed");
}
#[test]
fn accessors_need_no_bounds_on_the_error() {
struct Opaque;
let e = RetryError::new(Opaque, 2, Duration::from_secs(1), StopReason::MaxElapsed);
assert_eq!(e.attempts(), 2);
assert_eq!(e.elapsed(), Duration::from_secs(1));
assert_eq!(e.stop_reason(), StopReason::MaxElapsed);
let Opaque = e.into_error();
}
#[test]
fn boxes_into_dyn_error_for_types_that_are_not_error() {
fn boxed<E: std::fmt::Debug + std::fmt::Display + 'static>(
e: RetryError<E>,
) -> Box<dyn std::error::Error> {
Box::new(e)
}
let _ = boxed(err());
let _ = boxed(RetryError::new(
String::from("oops"),
1,
Duration::ZERO,
StopReason::NotRetryable,
));
let _: Box<dyn std::error::Error> = Box::new(RetryError::new(
Box::<dyn std::error::Error>::from("inner"),
1,
Duration::ZERO,
StopReason::NotRetryable,
));
}
}