use std::fmt;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("Configuration error: {0}")]
Configuration(String),
#[error("Validation error: {0}")]
Validation(String),
#[error("Pipeline error: {0}")]
Pipeline(String),
#[error("Context error: {0}")]
Context(String),
#[error("Plugin error: {0}")]
Plugin(String),
#[error("AI provider error: {0}")]
Provider(String),
#[error("Network error: {0}")]
Network(String),
#[error("Operation timed out after {0:?}")]
Timeout(std::time::Duration),
#[error("Rate limit exceeded")]
RateLimit,
#[error("Authentication failed: {0}")]
Authentication(String),
#[error("Authorization failed: {0}")]
Authorization(String),
#[error("Resource not found: {0}")]
NotFound(String),
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error("Serialization error: {0}")]
Serialization(String),
#[error("Database error: {0}")]
Database(String),
#[error("Cache error: {0}")]
Cache(String),
#[error("Initialization failed: {0}")]
Initialization(String),
#[error("Internal error: {0}")]
Internal(String),
#[error("{message}")]
Other {
message: String,
#[source]
source: Option<Box<dyn std::error::Error + Send + Sync>>,
},
}
impl Error {
pub fn new(message: impl Into<String>) -> Self {
Self::Other {
message: message.into(),
source: None,
}
}
pub fn with_source(
message: impl Into<String>,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self::Other {
message: message.into(),
source: Some(Box::new(source)),
}
}
#[must_use]
pub const fn is_retryable(&self) -> bool {
matches!(
self,
Self::Network(_) | Self::Timeout(_) | Self::RateLimit | Self::Provider(_)
)
}
#[must_use]
pub const fn is_client_error(&self) -> bool {
matches!(
self,
Self::InvalidInput(_)
| Self::Validation(_)
| Self::Authentication(_)
| Self::Authorization(_)
| Self::NotFound(_)
)
}
#[must_use]
pub const fn is_server_error(&self) -> bool {
matches!(
self,
Self::Internal(_) | Self::Database(_) | Self::Cache(_) | Self::Initialization(_)
)
}
#[must_use]
pub const fn error_code(&self) -> &'static str {
match self {
Self::Configuration(_) => "E001",
Self::Validation(_) => "E002",
Self::Pipeline(_) => "E003",
Self::Context(_) => "E004",
Self::Plugin(_) => "E005",
Self::Provider(_) => "E006",
Self::Network(_) => "E007",
Self::Timeout(_) => "E008",
Self::RateLimit => "E009",
Self::Authentication(_) => "E010",
Self::Authorization(_) => "E011",
Self::NotFound(_) => "E012",
Self::InvalidInput(_) => "E013",
Self::Serialization(_) => "E014",
Self::Database(_) => "E015",
Self::Cache(_) => "E016",
Self::Initialization(_) => "E017",
Self::Internal(_) => "E018",
Self::Other { .. } => "E999",
}
}
#[must_use]
pub const fn http_status_code(&self) -> u16 {
match self {
Self::InvalidInput(_) | Self::Validation(_) => 400,
Self::Authentication(_) => 401,
Self::Authorization(_) => 403,
Self::NotFound(_) => 404,
Self::Timeout(_) => 408,
Self::RateLimit => 429,
Self::Network(_) | Self::Provider(_) => 502,
Self::Initialization(_) => 503,
_ => 500,
}
}
}
pub type Result<T> = std::result::Result<T, Error>;
pub trait ErrorContext<T> {
fn context(self, msg: impl fmt::Display) -> Result<T>;
fn with_context<F>(self, f: F) -> Result<T>
where
F: FnOnce() -> String;
}
impl<T, E> ErrorContext<T> for std::result::Result<T, E>
where
E: std::error::Error + Send + Sync + 'static,
{
fn context(self, msg: impl fmt::Display) -> Result<T> {
self.map_err(|e| Error::with_source(msg.to_string(), e))
}
fn with_context<F>(self, f: F) -> Result<T>
where
F: FnOnce() -> String,
{
self.map_err(|e| Error::with_source(f(), e))
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct ErrorResponse {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
}
impl From<Error> for ErrorResponse {
fn from(error: Error) -> Self {
Self {
code: error.error_code().to_string(),
message: error.to_string(),
details: None,
request_id: None,
}
}
}
impl ErrorResponse {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
details: None,
request_id: None,
}
}
#[must_use]
pub fn with_details(mut self, details: serde_json::Value) -> Self {
self.details = Some(details);
self
}
#[must_use]
pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
self.request_id = Some(request_id.into());
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error as StdError;
#[test]
fn test_error_creation() {
let error = Error::new("test error");
assert_eq!(error.to_string(), "test error");
assert_eq!(error.error_code(), "E999");
}
#[test]
fn test_error_with_source() {
let source = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let error = Error::with_source("wrapper error", source);
assert_eq!(error.to_string(), "wrapper error");
assert!(StdError::source(&error).is_some());
}
#[test]
fn test_retryable_errors() {
assert!(Error::Network("network error".into()).is_retryable());
assert!(Error::Timeout(std::time::Duration::from_secs(30)).is_retryable());
assert!(Error::RateLimit.is_retryable());
assert!(Error::Provider("provider error".into()).is_retryable());
assert!(!Error::InvalidInput("bad input".into()).is_retryable());
assert!(!Error::Authentication("auth failed".into()).is_retryable());
}
#[test]
fn test_client_errors() {
assert!(Error::InvalidInput("bad input".into()).is_client_error());
assert!(Error::Validation("validation failed".into()).is_client_error());
assert!(Error::Authentication("auth failed".into()).is_client_error());
assert!(Error::Authorization("not authorized".into()).is_client_error());
assert!(Error::NotFound("not found".into()).is_client_error());
assert!(!Error::Internal("internal error".into()).is_client_error());
assert!(!Error::Database("db error".into()).is_client_error());
}
#[test]
fn test_server_errors() {
assert!(Error::Internal("internal error".into()).is_server_error());
assert!(Error::Database("db error".into()).is_server_error());
assert!(Error::Cache("cache error".into()).is_server_error());
assert!(Error::Initialization("init failed".into()).is_server_error());
assert!(!Error::InvalidInput("bad input".into()).is_server_error());
assert!(!Error::Authentication("auth failed".into()).is_server_error());
}
#[test]
fn test_http_status_codes() {
assert_eq!(Error::InvalidInput("bad".into()).http_status_code(), 400);
assert_eq!(Error::Validation("bad".into()).http_status_code(), 400);
assert_eq!(Error::Authentication("auth".into()).http_status_code(), 401);
assert_eq!(Error::Authorization("authz".into()).http_status_code(), 403);
assert_eq!(Error::NotFound("404".into()).http_status_code(), 404);
assert_eq!(
Error::Timeout(std::time::Duration::from_secs(30)).http_status_code(),
408
);
assert_eq!(Error::RateLimit.http_status_code(), 429);
assert_eq!(Error::Internal("500".into()).http_status_code(), 500);
assert_eq!(Error::Network("net".into()).http_status_code(), 502);
assert_eq!(Error::Initialization("init".into()).http_status_code(), 503);
}
#[test]
fn test_error_response() {
let error = Error::InvalidInput("bad input".into());
let response = ErrorResponse::from(error);
assert_eq!(response.code, "E013");
assert_eq!(response.message, "Invalid input: bad input");
assert!(response.details.is_none());
assert!(response.request_id.is_none());
}
#[test]
fn test_error_response_with_details() {
let response = ErrorResponse::new("E001", "test error")
.with_details(serde_json::json!({"field": "name"}))
.with_request_id("req-123");
assert_eq!(response.code, "E001");
assert_eq!(response.message, "test error");
assert!(response.details.is_some());
assert_eq!(response.request_id.as_deref(), Some("req-123"));
}
#[test]
fn test_error_context() {
let result: std::result::Result<(), std::io::Error> = Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"file not found",
));
let error = result.context("Failed to read file").unwrap_err();
assert_eq!(error.to_string(), "Failed to read file");
}
}