use std::error::Error;
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Class {
Network,
Unavailable {
code: Option<Arc<str>>,
},
RateLimited {
retry_after: Option<Duration>,
},
Unauthorized,
InvalidRequest,
VenueRefusal {
code: Option<Arc<str>>,
},
Restricted,
Decode,
}
impl Class {
pub fn is_retriable(&self) -> bool {
matches!(
self,
Self::Network | Self::Unavailable { .. } | Self::RateLimited { .. }
)
}
pub fn retry_after(&self) -> Option<Duration> {
match self {
Self::RateLimited { retry_after } => retry_after.filter(|wait| !wait.is_zero()),
_ => None,
}
}
pub fn code(&self) -> Option<&str> {
match self {
Self::Unavailable { code } | Self::VenueRefusal { code } => code.as_deref(),
_ => None,
}
}
pub fn with_code(self, code: impl Into<Arc<str>>) -> Self {
match self {
Self::Unavailable { .. } => Self::Unavailable {
code: Some(code.into()),
},
Self::VenueRefusal { .. } => Self::VenueRefusal {
code: Some(code.into()),
},
other => other,
}
}
pub fn with_retry_after(self, retry_after: Option<Duration>) -> Self {
match self {
Self::RateLimited { .. } => Self::RateLimited {
retry_after: retry_after.filter(|wait| !wait.is_zero()),
},
other => other,
}
}
}
pub trait Classify: Error {
fn class(&self) -> Class;
fn is_fault(&self) -> bool {
true
}
fn retry_after(&self) -> Option<Duration> {
self.class().retry_after()
}
fn is_retriable(&self) -> bool {
self.class().is_retriable()
}
}
#[derive(Debug)]
pub struct ClassifiedError {
pub class: Class,
pub source: Box<dyn Error + Send + Sync + 'static>,
fault: bool,
retry_after: Option<Duration>,
}
impl ClassifiedError {
pub fn new(class: Class, source: impl Into<Box<dyn Error + Send + Sync + 'static>>) -> Self {
let retry_after = class.retry_after();
Self {
class,
source: source.into(),
fault: true,
retry_after,
}
}
pub fn is_fault(&self) -> bool {
self.fault
}
pub fn retry_after(&self) -> Option<Duration> {
self.retry_after
}
pub fn is_retriable(&self) -> bool {
self.class.is_retriable()
}
}
impl fmt::Display for ClassifiedError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.source, f)
}
}
impl Error for ClassifiedError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
self.source.source()
}
}
impl<E> From<E> for ClassifiedError
where
E: Classify + Send + Sync + 'static,
{
fn from(err: E) -> Self {
Self {
class: err.class(),
fault: err.is_fault(),
retry_after: err.retry_after().filter(|wait| !wait.is_zero()),
source: Box::new(err),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug)]
struct Banned;
impl fmt::Display for Banned {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("banned")
}
}
impl Error for Banned {}
impl Classify for Banned {
fn class(&self) -> Class {
Class::Restricted
}
fn is_fault(&self) -> bool {
false
}
fn retry_after(&self) -> Option<Duration> {
Some(Duration::from_secs(120))
}
}
#[derive(Debug)]
struct Throttled;
impl fmt::Display for Throttled {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("throttled")
}
}
impl Error for Throttled {}
impl Classify for Throttled {
fn class(&self) -> Class {
Class::RateLimited {
retry_after: Some(Duration::from_secs(3)),
}
}
}
#[test]
fn only_network_unavailable_and_rate_limited_are_retriable() {
let classes = [
(Class::Network, true),
(Class::Unavailable { code: None }, true),
(Class::RateLimited { retry_after: None }, true),
(Class::Unauthorized, false),
(Class::InvalidRequest, false),
(Class::VenueRefusal { code: None }, false),
(Class::Restricted, false),
(Class::Decode, false),
];
for (class, retriable) in classes {
assert_eq!(class.is_retriable(), retriable, "{class:?}");
}
}
#[test]
fn a_code_and_a_wait_land_only_where_a_class_has_room_for_them() {
assert_eq!(
Class::VenueRefusal { code: None }.with_code("-1121").code(),
Some("-1121")
);
assert_eq!(
Class::Unavailable { code: None }
.with_code("internal")
.code(),
Some("internal")
);
assert_eq!(
Class::Unauthorized.with_code("x"),
Class::Unauthorized,
"a class without a code field is unchanged"
);
let wait = Some(Duration::from_secs(2));
assert_eq!(
Class::RateLimited { retry_after: None }
.with_retry_after(wait)
.retry_after(),
wait
);
assert_eq!(
Class::RateLimited { retry_after: None }
.with_retry_after(Some(Duration::ZERO))
.retry_after(),
None,
"a zero wait asks for nothing"
);
assert_eq!(
Class::RateLimited {
retry_after: Some(Duration::ZERO)
}
.retry_after(),
None,
"nor when the class was built with one"
);
assert_eq!(Class::Restricted.with_retry_after(wait), Class::Restricted);
}
#[test]
fn the_defaults_read_the_class() {
assert!(Throttled.is_fault());
assert_eq!(
Classify::retry_after(&Throttled),
Some(Duration::from_secs(3))
);
assert!(Classify::is_retriable(&Throttled));
}
#[test]
fn a_classified_error_keeps_what_the_error_reported() {
let err = ClassifiedError::from(Banned);
assert_eq!(err.class, Class::Restricted);
assert!(!err.is_fault());
assert_eq!(err.retry_after(), Some(Duration::from_secs(120)));
assert!(!err.is_retriable());
assert!(err.source.downcast_ref::<Banned>().is_some());
}
#[test]
fn a_classified_error_displays_as_its_source_and_continues_its_chain() {
let err: ClassifiedError = Throttled.into();
assert_eq!(err.to_string(), "throttled");
assert!(
Error::source(&err).is_none(),
"Throttled has no source of its own"
);
}
#[derive(Debug)]
struct ZeroWait;
impl fmt::Display for ZeroWait {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("zero wait")
}
}
impl Error for ZeroWait {}
impl Classify for ZeroWait {
fn class(&self) -> Class {
Class::Unavailable { code: None }
}
fn retry_after(&self) -> Option<Duration> {
Some(Duration::ZERO)
}
}
#[test]
fn a_classified_error_never_keeps_a_zero_wait() {
assert_eq!(ClassifiedError::from(ZeroWait).retry_after(), None);
}
#[test]
fn an_unclassified_error_takes_the_class_it_is_given() {
let err = ClassifiedError::new(
Class::RateLimited {
retry_after: Some(Duration::from_secs(1)),
},
"slow down",
);
assert!(err.is_fault());
assert_eq!(err.retry_after(), Some(Duration::from_secs(1)));
assert_eq!(err.to_string(), "slow down");
}
#[test]
fn the_question_mark_operator_classifies() {
fn fails() -> Result<(), ClassifiedError> {
let banned: Result<(), Banned> = Err(Banned);
banned?;
Ok(())
}
assert_eq!(fails().unwrap_err().class, Class::Restricted);
}
}