use std::fmt;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ServiceError {
#[error("Database error: {message}")]
Database {
message: String,
#[source]
source: Option<Box<dyn std::error::Error + Send + Sync>>,
},
#[error("Not found: {resource_type} with {identifier}")]
NotFound {
resource_type: String,
identifier: String,
},
#[error("Invalid input: {message}")]
InvalidInput {
message: String,
field: Option<String>,
},
#[error("Authentication required: {0}")]
Unauthenticated(String),
#[error("Permission denied: {0}")]
PermissionDenied(String),
#[error("Business rule violation: {0}")]
BusinessRule(String),
#[error("External service error: {service} - {message}")]
ExternalService {
service: String,
message: String,
#[source]
source: Option<Box<dyn std::error::Error + Send + Sync>>,
},
#[error("Configuration error: {0}")]
Configuration(String),
#[error("Rate limit exceeded: {0}")]
RateLimitExceeded(String),
#[error("Conflict: {0}")]
Conflict(String),
#[error("Cache error: {message}")]
Cache {
message: String,
#[source]
source: Option<Box<dyn std::error::Error + Send + Sync>>,
},
#[error("Timeout: {operation} exceeded {timeout_ms}ms")]
Timeout {
operation: String,
timeout_ms: u64,
},
#[error("Resource exhausted: {resource} - {message}")]
ResourceExhausted {
resource: String,
message: String,
},
#[error("Validation failed: {0:?}")]
ValidationErrors(std::collections::HashMap<String, Vec<String>>),
#[error("Internal error: {message}")]
Internal {
message: String,
#[source]
source: Option<Box<dyn std::error::Error + Send + Sync>>,
},
}
impl ServiceError {
pub fn database<E>(message: impl Into<String>, error: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
Self::Database {
message: message.into(),
source: Some(Box::new(error)),
}
}
pub fn database_msg(message: impl Into<String>) -> Self {
Self::Database {
message: message.into(),
source: None,
}
}
pub fn not_found(resource_type: impl Into<String>, id: impl fmt::Display) -> Self {
Self::NotFound {
resource_type: resource_type.into(),
identifier: id.to_string(),
}
}
pub fn invalid_input(message: impl Into<String>) -> Self {
Self::InvalidInput {
message: message.into(),
field: None,
}
}
pub fn invalid_field(field: impl Into<String>, message: impl Into<String>) -> Self {
Self::InvalidInput {
message: message.into(),
field: Some(field.into()),
}
}
pub fn external_service<E>(service: impl Into<String>, message: impl Into<String>, error: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
Self::ExternalService {
service: service.into(),
message: message.into(),
source: Some(Box::new(error)),
}
}
pub fn internal<E>(message: impl Into<String>, error: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
Self::Internal {
message: message.into(),
source: Some(Box::new(error)),
}
}
pub fn internal_msg(message: impl Into<String>) -> Self {
Self::Internal {
message: message.into(),
source: None,
}
}
pub fn cache<E>(message: impl Into<String>, error: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
Self::Cache {
message: message.into(),
source: Some(Box::new(error)),
}
}
pub fn cache_msg(message: impl Into<String>) -> Self {
Self::Cache {
message: message.into(),
source: None,
}
}
pub fn timeout(operation: impl Into<String>, timeout_ms: u64) -> Self {
Self::Timeout {
operation: operation.into(),
timeout_ms,
}
}
pub fn resource_exhausted(resource: impl Into<String>, message: impl Into<String>) -> Self {
Self::ResourceExhausted {
resource: resource.into(),
message: message.into(),
}
}
#[cfg(feature = "context")]
pub fn context(self, message: impl Into<String>) -> Self {
Self::Internal {
message: format!("{}: {}", message.into(), self),
source: Some(Box::new(self)),
}
}
pub fn is_retryable(&self) -> bool {
matches!(
self,
Self::Database { .. }
| Self::ExternalService { .. }
| Self::Cache { .. }
| Self::Timeout { .. }
| Self::ResourceExhausted { .. }
| Self::RateLimitExceeded(_)
| Self::Internal { .. }
)
}
pub fn is_severe(&self) -> bool {
matches!(
self,
Self::Database { .. }
| Self::Internal { .. }
| Self::Configuration(_)
| Self::ResourceExhausted { .. }
)
}
}
pub type Result<T> = std::result::Result<T, ServiceError>;
#[cfg(feature = "context")]
pub use anyhow::Context;
pub mod field_validator;
pub use field_validator::{FieldValidator, validation_errors_from_fields, validation_from_fields};
#[cfg(feature = "context")]
impl From<anyhow::Error> for ServiceError {
fn from(error: anyhow::Error) -> Self {
Self::internal_msg(error.to_string())
}
}
#[cfg(feature = "serialization")]
use serde::{Deserialize, Serialize};
#[cfg(feature = "serialization")]
#[derive(Serialize, Deserialize, Debug)]
pub struct ErrorResponse {
pub error: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
}
#[cfg(feature = "serialization")]
impl From<ServiceError> for ErrorResponse {
fn from(error: ServiceError) -> Self {
let error_type = match &error {
ServiceError::Database { .. } => "DATABASE_ERROR",
ServiceError::NotFound { .. } => "NOT_FOUND",
ServiceError::InvalidInput { .. } => "INVALID_INPUT",
ServiceError::ValidationErrors(_) => "VALIDATION_ERRORS",
ServiceError::Unauthenticated(_) => "UNAUTHENTICATED",
ServiceError::PermissionDenied(_) => "PERMISSION_DENIED",
ServiceError::BusinessRule(_) => "BUSINESS_RULE_VIOLATION",
ServiceError::ExternalService { .. } => "EXTERNAL_SERVICE_ERROR",
ServiceError::Configuration(_) => "CONFIGURATION_ERROR",
ServiceError::RateLimitExceeded(_) => "RATE_LIMIT_EXCEEDED",
ServiceError::Conflict(_) => "CONFLICT",
ServiceError::Cache { .. } => "CACHE_ERROR",
ServiceError::Timeout { .. } => "TIMEOUT",
ServiceError::ResourceExhausted { .. } => "RESOURCE_EXHAUSTED",
ServiceError::Internal { .. } => "INTERNAL_ERROR",
};
let field = match &error {
ServiceError::InvalidInput { field, .. } => field.clone(),
_ => None,
};
Self {
error: error_type.to_string(),
message: error.to_string(),
field,
details: None,
}
}
}
#[cfg(feature = "graphql")]
use async_graphql::ErrorExtensions;
#[cfg(feature = "graphql")]
impl ServiceError {
pub fn into_graphql_error(self) -> async_graphql::Error {
let message = self.to_string();
let code = match &self {
ServiceError::NotFound { .. } => "NOT_FOUND",
ServiceError::InvalidInput { .. } => "INVALID_INPUT",
ServiceError::ValidationErrors(_) => "VALIDATION_ERRORS",
ServiceError::Unauthenticated(_) => "UNAUTHENTICATED",
ServiceError::PermissionDenied(_) => "PERMISSION_DENIED",
ServiceError::BusinessRule(_) => "BUSINESS_RULE_VIOLATION",
ServiceError::RateLimitExceeded(_) => "RATE_LIMIT_EXCEEDED",
ServiceError::Conflict(_) => "CONFLICT",
ServiceError::Cache { .. } => "CACHE_ERROR",
ServiceError::Timeout { .. } => "TIMEOUT",
ServiceError::ResourceExhausted { .. } => "RESOURCE_EXHAUSTED",
_ => "INTERNAL_ERROR",
};
let field = match &self {
ServiceError::InvalidInput { field, .. } => field.clone(),
_ => None,
};
let retryable = self.is_retryable();
let severe = self.is_severe();
let mut error = async_graphql::Error::new(message);
error = error.extend_with(|_, e| {
e.set("code", code);
e.set("retryable", retryable);
e.set("severe", severe);
if let Some(field_name) = field {
e.set("field", field_name);
}
if let ServiceError::Timeout { operation, timeout_ms } = &self {
e.set("operation", operation.clone());
e.set("timeout_ms", *timeout_ms);
}
if let ServiceError::ResourceExhausted { resource, .. } = &self {
e.set("resource", resource.clone());
}
});
error
}
}
#[cfg(feature = "http-errors")]
impl From<ServiceError> for http::StatusCode {
fn from(error: ServiceError) -> Self {
match error {
ServiceError::NotFound { .. } => http::StatusCode::NOT_FOUND,
ServiceError::InvalidInput { .. } => http::StatusCode::BAD_REQUEST,
ServiceError::ValidationErrors(_) => http::StatusCode::BAD_REQUEST,
ServiceError::Unauthenticated(_) => http::StatusCode::UNAUTHORIZED,
ServiceError::PermissionDenied(_) => http::StatusCode::FORBIDDEN,
ServiceError::BusinessRule(_) => http::StatusCode::UNPROCESSABLE_ENTITY,
ServiceError::RateLimitExceeded(_) => http::StatusCode::TOO_MANY_REQUESTS,
ServiceError::Conflict(_) => http::StatusCode::CONFLICT,
ServiceError::Timeout { .. } => http::StatusCode::GATEWAY_TIMEOUT,
ServiceError::ResourceExhausted { .. } => http::StatusCode::SERVICE_UNAVAILABLE,
ServiceError::Database { .. }
| ServiceError::Cache { .. }
| ServiceError::ExternalService { .. }
| ServiceError::Configuration(_)
| ServiceError::Internal { .. } => http::StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
#[cfg(feature = "http-errors")]
impl axum::response::IntoResponse for ServiceError {
fn into_response(self) -> axum::response::Response {
let status = match &self {
ServiceError::NotFound { .. } => http::StatusCode::NOT_FOUND,
ServiceError::InvalidInput { .. } => http::StatusCode::BAD_REQUEST,
ServiceError::ValidationErrors(_) => http::StatusCode::BAD_REQUEST,
ServiceError::Unauthenticated(_) => http::StatusCode::UNAUTHORIZED,
ServiceError::PermissionDenied(_) => http::StatusCode::FORBIDDEN,
ServiceError::BusinessRule(_) => http::StatusCode::UNPROCESSABLE_ENTITY,
ServiceError::RateLimitExceeded(_) => http::StatusCode::TOO_MANY_REQUESTS,
ServiceError::Conflict(_) => http::StatusCode::CONFLICT,
ServiceError::Timeout { .. } => http::StatusCode::GATEWAY_TIMEOUT,
ServiceError::ResourceExhausted { .. } => http::StatusCode::SERVICE_UNAVAILABLE,
ServiceError::Database { .. }
| ServiceError::Cache { .. }
| ServiceError::ExternalService { .. }
| ServiceError::Configuration(_)
| ServiceError::Internal { .. } => http::StatusCode::INTERNAL_SERVER_ERROR,
};
#[cfg(feature = "serialization")]
{
let body = ErrorResponse::from(self);
(status, axum::Json(body)).into_response()
}
#[cfg(not(feature = "serialization"))]
{
(status, self.to_string()).into_response()
}
}
}
#[cfg(feature = "database")]
impl From<sqlx::Error> for ServiceError {
fn from(error: sqlx::Error) -> Self {
match error {
sqlx::Error::RowNotFound => {
Self::not_found("record", "unknown")
}
sqlx::Error::Database(db_err) => {
if let Some(constraint) = db_err.constraint() {
Self::Conflict(format!("Constraint violation: {}", constraint))
} else {
Self::database_msg(db_err.message())
}
}
_ => Self::database("Database operation failed", error),
}
}
}
#[cfg(feature = "database")]
impl From<redis::RedisError> for ServiceError {
fn from(error: redis::RedisError) -> Self {
Self::database("Redis operation failed", error)
}
}
#[cfg(feature = "serialization")]
impl From<serde_json::Error> for ServiceError {
fn from(error: serde_json::Error) -> Self {
Self::InvalidInput {
message: format!("JSON parsing failed: {}", error),
field: None,
}
}
}
impl From<url::ParseError> for ServiceError {
fn from(error: url::ParseError) -> Self {
Self::InvalidInput {
message: format!("URL parsing failed: {}", error),
field: None,
}
}
}
impl From<std::io::Error> for ServiceError {
fn from(error: std::io::Error) -> Self {
match error.kind() {
std::io::ErrorKind::NotFound => Self::NotFound {
resource_type: "file".to_string(),
identifier: error.to_string(),
},
std::io::ErrorKind::PermissionDenied => {
Self::PermissionDenied(format!("I/O permission denied: {}", error))
}
std::io::ErrorKind::TimedOut => Self::timeout("I/O operation", 0),
_ => Self::internal("I/O error", error),
}
}
}
#[cfg(feature = "logging")]
pub fn log_error(error: &ServiceError, context: &str) {
if error.is_severe() {
tracing::error!(
error = %error,
context = context,
"Service error occurred"
);
} else {
tracing::warn!(
error = %error,
context = context,
"Service error occurred"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
#[test]
fn test_error_creation() {
let err = ServiceError::not_found("User", Uuid::new_v4());
assert!(matches!(err, ServiceError::NotFound { .. }));
let err = ServiceError::invalid_input("Invalid email");
assert!(matches!(err, ServiceError::InvalidInput { .. }));
let err = ServiceError::invalid_field("email", "Must be valid");
assert!(matches!(err, ServiceError::InvalidInput { field: Some(_), .. }));
}
#[test]
fn test_error_retryable() {
assert!(ServiceError::database_msg("Connection failed").is_retryable());
assert!(ServiceError::RateLimitExceeded("Too many requests".to_string()).is_retryable());
assert!(!ServiceError::not_found("User", "123").is_retryable());
assert!(!ServiceError::InvalidInput {
message: "Bad input".to_string(),
field: None
}
.is_retryable());
}
#[test]
fn test_error_severity() {
assert!(ServiceError::database_msg("Connection failed").is_severe());
assert!(ServiceError::internal_msg("Panic").is_severe());
assert!(!ServiceError::not_found("User", "123").is_severe());
assert!(!ServiceError::InvalidInput {
message: "Bad input".to_string(),
field: None
}
.is_severe());
}
#[cfg(feature = "serialization")]
#[test]
fn test_error_serialization() {
let err = ServiceError::not_found("User", "123");
let response = ErrorResponse::from(err);
assert_eq!(response.error, "NOT_FOUND");
assert!(response.message.contains("User"));
}
#[cfg(feature = "http-errors")]
#[test]
fn test_http_status_conversion() {
use http::StatusCode;
assert_eq!(
StatusCode::from(ServiceError::not_found("User", "123")),
StatusCode::NOT_FOUND
);
assert_eq!(
StatusCode::from(ServiceError::invalid_input("Bad")),
StatusCode::BAD_REQUEST
);
assert_eq!(
StatusCode::from(ServiceError::Unauthenticated("Login required".to_string())),
StatusCode::UNAUTHORIZED
);
assert_eq!(
StatusCode::from(ServiceError::timeout("database query", 5000)),
StatusCode::GATEWAY_TIMEOUT
);
assert_eq!(
StatusCode::from(ServiceError::resource_exhausted("memory", "Out of memory")),
StatusCode::SERVICE_UNAVAILABLE
);
}
#[test]
fn test_new_error_variants() {
let cache_err = ServiceError::cache_msg("Cache miss");
assert!(matches!(cache_err, ServiceError::Cache { .. }));
assert!(cache_err.is_retryable());
let timeout_err = ServiceError::timeout("API call", 3000);
assert!(matches!(timeout_err, ServiceError::Timeout { .. }));
assert!(timeout_err.is_retryable());
assert!(timeout_err.to_string().contains("3000ms"));
let exhausted_err = ServiceError::resource_exhausted("connections", "Pool exhausted");
assert!(matches!(exhausted_err, ServiceError::ResourceExhausted { .. }));
assert!(exhausted_err.is_retryable());
assert!(exhausted_err.is_severe());
}
#[cfg(feature = "context")]
#[test]
fn test_context_method() {
let err = ServiceError::not_found("User", "123");
let with_context = err.context("Failed to load user profile");
assert!(matches!(with_context, ServiceError::Internal { .. }));
assert!(with_context.to_string().contains("Failed to load user profile"));
assert!(with_context.to_string().contains("User"));
}
#[cfg(feature = "graphql")]
#[test]
fn test_graphql_error_conversion() {
let timeout_err = ServiceError::timeout("database query", 5000);
let graphql_err = timeout_err.into_graphql_error();
assert!(graphql_err.message.contains("database query"));
}
#[cfg(feature = "serialization")]
#[test]
fn test_json_error_conversion() {
let json_str = r#"{"invalid": json"#;
let result: std::result::Result<serde_json::Value, serde_json::Error> = serde_json::from_str(json_str);
if let Err(json_err) = result {
let service_err = ServiceError::from(json_err);
assert!(matches!(service_err, ServiceError::InvalidInput { .. }));
assert!(service_err.to_string().contains("JSON parsing failed"));
}
}
#[test]
fn test_url_error_conversion() {
let invalid_url = "not a valid url";
let result = url::Url::parse(invalid_url);
if let Err(url_err) = result {
let service_err = ServiceError::from(url_err);
assert!(matches!(service_err, ServiceError::InvalidInput { .. }));
assert!(service_err.to_string().contains("URL parsing failed"));
}
}
}