use std::time::Duration;
use thiserror::Error;
pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
#[derive(Debug, Error)]
pub enum PluginError {
#[error("transient error: {message}")]
Transient {
message: String,
#[source]
source: Option<BoxError>,
},
#[error("rate limited")]
RateLimited {
retry_after: Option<Duration>,
#[source]
source: Option<BoxError>,
},
#[error("timeout")]
Timeout {
#[source]
source: Option<BoxError>,
},
#[error("invalid input: {message}")]
InvalidInput {
message: String,
#[source]
source: Option<BoxError>,
},
#[error("unauthorized: {message}")]
Unauthorized {
message: String,
#[source]
source: Option<BoxError>,
},
#[error("not found: {message}")]
NotFound {
message: String,
#[source]
source: Option<BoxError>,
},
#[error("internal error: {message}")]
Internal {
message: String,
#[source]
source: Option<BoxError>,
},
}
impl PluginError {
pub fn transient(message: impl Into<String>) -> Self {
Self::Transient {
message: message.into(),
source: None,
}
}
pub fn transient_with<E>(message: impl Into<String>, source: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
Self::Transient {
message: message.into(),
source: Some(Box::new(source)),
}
}
pub fn rate_limited(retry_after: Option<Duration>) -> Self {
Self::RateLimited {
retry_after,
source: None,
}
}
pub fn rate_limited_with<E>(retry_after: Option<Duration>, source: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
Self::RateLimited {
retry_after,
source: Some(Box::new(source)),
}
}
pub fn timeout() -> Self {
Self::Timeout { source: None }
}
pub fn timeout_with<E>(source: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
Self::Timeout {
source: Some(Box::new(source)),
}
}
pub fn invalid_input(message: impl Into<String>) -> Self {
Self::InvalidInput {
message: message.into(),
source: None,
}
}
pub fn invalid_input_with<E>(message: impl Into<String>, source: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
Self::InvalidInput {
message: message.into(),
source: Some(Box::new(source)),
}
}
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::Unauthorized {
message: message.into(),
source: None,
}
}
pub fn unauthorized_with<E>(message: impl Into<String>, source: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
Self::Unauthorized {
message: message.into(),
source: Some(Box::new(source)),
}
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::NotFound {
message: message.into(),
source: None,
}
}
pub fn not_found_with<E>(message: impl Into<String>, source: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
Self::NotFound {
message: message.into(),
source: Some(Box::new(source)),
}
}
pub fn internal(message: impl Into<String>) -> Self {
Self::Internal {
message: message.into(),
source: None,
}
}
pub fn internal_with<E>(message: impl Into<String>, source: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
Self::Internal {
message: message.into(),
source: Some(Box::new(source)),
}
}
#[must_use]
pub fn suggested_status(&self) -> u16 {
match self {
Self::InvalidInput { .. } => 400,
Self::Unauthorized { .. } => 401,
Self::NotFound { .. } => 404,
Self::RateLimited { .. } => 429,
Self::Internal { .. } => 500,
Self::Timeout { .. } => 504,
Self::Transient { .. } => 503,
}
}
#[must_use]
pub fn is_retryable(&self) -> bool {
matches!(
self,
Self::Transient { .. } | Self::RateLimited { .. } | Self::Timeout { .. }
)
}
#[must_use]
pub fn is_user_facing(&self) -> bool {
matches!(
self,
Self::InvalidInput { .. }
| Self::Unauthorized { .. }
| Self::NotFound { .. }
| Self::RateLimited { .. }
)
}
#[must_use]
pub fn retry_after(&self) -> Option<Duration> {
match self {
Self::RateLimited { retry_after, .. } => *retry_after,
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn suggested_status_codes() {
assert_eq!(PluginError::transient("x").suggested_status(), 503);
assert_eq!(PluginError::rate_limited(None).suggested_status(), 429);
assert_eq!(PluginError::timeout().suggested_status(), 504);
assert_eq!(PluginError::invalid_input("x").suggested_status(), 400);
assert_eq!(PluginError::unauthorized("x").suggested_status(), 401);
assert_eq!(PluginError::not_found("x").suggested_status(), 404);
assert_eq!(PluginError::internal("x").suggested_status(), 500);
}
#[test]
fn retryable_partition() {
assert!(PluginError::transient("x").is_retryable());
assert!(PluginError::rate_limited(None).is_retryable());
assert!(PluginError::timeout().is_retryable());
assert!(!PluginError::invalid_input("x").is_retryable());
assert!(!PluginError::unauthorized("x").is_retryable());
assert!(!PluginError::not_found("x").is_retryable());
assert!(!PluginError::internal("x").is_retryable());
}
#[test]
fn user_facing_partition() {
assert!(PluginError::invalid_input("x").is_user_facing());
assert!(PluginError::unauthorized("x").is_user_facing());
assert!(PluginError::not_found("x").is_user_facing());
assert!(PluginError::rate_limited(None).is_user_facing());
assert!(!PluginError::transient("x").is_user_facing());
assert!(!PluginError::timeout().is_user_facing());
assert!(!PluginError::internal("x").is_user_facing());
}
#[test]
fn source_chain_is_preserved() {
use std::error::Error;
let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "peer reset");
let plugin_err = PluginError::transient_with("HTTP request failed", io_err);
assert!(plugin_err.to_string().contains("HTTP request failed"));
let cause = plugin_err.source().expect("source must be preserved");
assert!(cause.to_string().contains("peer reset"));
}
#[test]
fn retry_after_only_set_for_rate_limited() {
assert_eq!(
PluginError::rate_limited(Some(Duration::from_secs(5))).retry_after(),
Some(Duration::from_secs(5))
);
assert_eq!(PluginError::rate_limited(None).retry_after(), None);
assert_eq!(PluginError::transient("x").retry_after(), None);
assert_eq!(PluginError::timeout().retry_after(), None);
}
}