1use std::fmt;
35use thiserror::Error;
36
37#[derive(Error, Debug)]
42pub enum ServiceError {
43 #[error("Database error: {message}")]
45 Database {
46 message: String,
47 #[source]
48 source: Option<Box<dyn std::error::Error + Send + Sync>>,
49 },
50
51 #[error("Not found: {resource_type} with {identifier}")]
53 NotFound {
54 resource_type: String,
55 identifier: String,
56 },
57
58 #[error("Invalid input: {message}")]
60 InvalidInput {
61 message: String,
62 field: Option<String>,
63 },
64
65 #[error("Authentication required: {0}")]
67 Unauthenticated(String),
68
69 #[error("Permission denied: {0}")]
71 PermissionDenied(String),
72
73 #[error("Business rule violation: {0}")]
75 BusinessRule(String),
76
77 #[error("External service error: {service} - {message}")]
79 ExternalService {
80 service: String,
81 message: String,
82 #[source]
83 source: Option<Box<dyn std::error::Error + Send + Sync>>,
84 },
85
86 #[error("Configuration error: {0}")]
88 Configuration(String),
89
90 #[error("Rate limit exceeded: {0}")]
92 RateLimitExceeded(String),
93
94 #[error("Conflict: {0}")]
96 Conflict(String),
97
98 #[error("Cache error: {message}")]
100 Cache {
101 message: String,
102 #[source]
103 source: Option<Box<dyn std::error::Error + Send + Sync>>,
104 },
105
106 #[error("Timeout: {operation} exceeded {timeout_ms}ms")]
108 Timeout {
109 operation: String,
110 timeout_ms: u64,
111 },
112
113 #[error("Resource exhausted: {resource} - {message}")]
115 ResourceExhausted {
116 resource: String,
117 message: String,
118 },
119
120 #[error("Validation failed: {0:?}")]
122 ValidationErrors(std::collections::HashMap<String, Vec<String>>),
123
124 #[error("Internal error: {message}")]
126 Internal {
127 message: String,
128 #[source]
129 source: Option<Box<dyn std::error::Error + Send + Sync>>,
130 },
131}
132
133impl ServiceError {
134 pub fn database<E>(message: impl Into<String>, error: E) -> Self
136 where
137 E: std::error::Error + Send + Sync + 'static,
138 {
139 Self::Database {
140 message: message.into(),
141 source: Some(Box::new(error)),
142 }
143 }
144
145 pub fn database_msg(message: impl Into<String>) -> Self {
147 Self::Database {
148 message: message.into(),
149 source: None,
150 }
151 }
152
153 pub fn not_found(resource_type: impl Into<String>, id: impl fmt::Display) -> Self {
155 Self::NotFound {
156 resource_type: resource_type.into(),
157 identifier: id.to_string(),
158 }
159 }
160
161 pub fn invalid_input(message: impl Into<String>) -> Self {
163 Self::InvalidInput {
164 message: message.into(),
165 field: None,
166 }
167 }
168
169 pub fn invalid_field(field: impl Into<String>, message: impl Into<String>) -> Self {
171 Self::InvalidInput {
172 message: message.into(),
173 field: Some(field.into()),
174 }
175 }
176
177 pub fn external_service<E>(service: impl Into<String>, message: impl Into<String>, error: E) -> Self
179 where
180 E: std::error::Error + Send + Sync + 'static,
181 {
182 Self::ExternalService {
183 service: service.into(),
184 message: message.into(),
185 source: Some(Box::new(error)),
186 }
187 }
188
189 pub fn internal<E>(message: impl Into<String>, error: E) -> Self
191 where
192 E: std::error::Error + Send + Sync + 'static,
193 {
194 Self::Internal {
195 message: message.into(),
196 source: Some(Box::new(error)),
197 }
198 }
199
200 pub fn internal_msg(message: impl Into<String>) -> Self {
202 Self::Internal {
203 message: message.into(),
204 source: None,
205 }
206 }
207
208 pub fn cache<E>(message: impl Into<String>, error: E) -> Self
210 where
211 E: std::error::Error + Send + Sync + 'static,
212 {
213 Self::Cache {
214 message: message.into(),
215 source: Some(Box::new(error)),
216 }
217 }
218
219 pub fn cache_msg(message: impl Into<String>) -> Self {
221 Self::Cache {
222 message: message.into(),
223 source: None,
224 }
225 }
226
227 pub fn timeout(operation: impl Into<String>, timeout_ms: u64) -> Self {
229 Self::Timeout {
230 operation: operation.into(),
231 timeout_ms,
232 }
233 }
234
235 pub fn resource_exhausted(resource: impl Into<String>, message: impl Into<String>) -> Self {
237 Self::ResourceExhausted {
238 resource: resource.into(),
239 message: message.into(),
240 }
241 }
242
243 #[cfg(feature = "context")]
251 pub fn context(self, message: impl Into<String>) -> Self {
252 Self::Internal {
253 message: format!("{}: {}", message.into(), self),
254 source: Some(Box::new(self)),
255 }
256 }
257
258 pub fn is_retryable(&self) -> bool {
260 matches!(
261 self,
262 Self::Database { .. }
263 | Self::ExternalService { .. }
264 | Self::Cache { .. }
265 | Self::Timeout { .. }
266 | Self::ResourceExhausted { .. }
267 | Self::RateLimitExceeded(_)
268 | Self::Internal { .. }
269 )
270 }
271
272 pub fn is_severe(&self) -> bool {
274 matches!(
275 self,
276 Self::Database { .. }
277 | Self::Internal { .. }
278 | Self::Configuration(_)
279 | Self::ResourceExhausted { .. }
280 )
281 }
282}
283
284pub type Result<T> = std::result::Result<T, ServiceError>;
289
290#[cfg(feature = "context")]
295pub use anyhow::Context;
296
297pub mod field_validator;
299pub use field_validator::{FieldValidator, validation_errors_from_fields, validation_from_fields};
300
301#[cfg(feature = "context")]
302impl From<anyhow::Error> for ServiceError {
303 fn from(error: anyhow::Error) -> Self {
304 Self::internal_msg(error.to_string())
305 }
306}
307
308#[cfg(feature = "serialization")]
313use serde::{Deserialize, Serialize};
314
315#[cfg(feature = "serialization")]
316#[derive(Serialize, Deserialize, Debug)]
317pub struct ErrorResponse {
318 pub error: String,
319 pub message: String,
320 #[serde(skip_serializing_if = "Option::is_none")]
321 pub field: Option<String>,
322 #[serde(skip_serializing_if = "Option::is_none")]
323 pub details: Option<serde_json::Value>,
324}
325
326#[cfg(feature = "serialization")]
327impl From<ServiceError> for ErrorResponse {
328 fn from(error: ServiceError) -> Self {
329 let error_type = match &error {
330 ServiceError::Database { .. } => "DATABASE_ERROR",
331 ServiceError::NotFound { .. } => "NOT_FOUND",
332 ServiceError::InvalidInput { .. } => "INVALID_INPUT",
333 ServiceError::ValidationErrors(_) => "VALIDATION_ERRORS",
334 ServiceError::Unauthenticated(_) => "UNAUTHENTICATED",
335 ServiceError::PermissionDenied(_) => "PERMISSION_DENIED",
336 ServiceError::BusinessRule(_) => "BUSINESS_RULE_VIOLATION",
337 ServiceError::ExternalService { .. } => "EXTERNAL_SERVICE_ERROR",
338 ServiceError::Configuration(_) => "CONFIGURATION_ERROR",
339 ServiceError::RateLimitExceeded(_) => "RATE_LIMIT_EXCEEDED",
340 ServiceError::Conflict(_) => "CONFLICT",
341 ServiceError::Cache { .. } => "CACHE_ERROR",
342 ServiceError::Timeout { .. } => "TIMEOUT",
343 ServiceError::ResourceExhausted { .. } => "RESOURCE_EXHAUSTED",
344 ServiceError::Internal { .. } => "INTERNAL_ERROR",
345 };
346
347 let field = match &error {
348 ServiceError::InvalidInput { field, .. } => field.clone(),
349 _ => None,
350 };
351
352 Self {
353 error: error_type.to_string(),
354 message: error.to_string(),
355 field,
356 details: None,
357 }
358 }
359}
360
361#[cfg(feature = "graphql")]
366use async_graphql::ErrorExtensions;
367
368#[cfg(feature = "graphql")]
369impl ServiceError {
370 pub fn into_graphql_error(self) -> async_graphql::Error {
379 let message = self.to_string();
380
381 let code = match &self {
383 ServiceError::NotFound { .. } => "NOT_FOUND",
384 ServiceError::InvalidInput { .. } => "INVALID_INPUT",
385 ServiceError::ValidationErrors(_) => "VALIDATION_ERRORS",
386 ServiceError::Unauthenticated(_) => "UNAUTHENTICATED",
387 ServiceError::PermissionDenied(_) => "PERMISSION_DENIED",
388 ServiceError::BusinessRule(_) => "BUSINESS_RULE_VIOLATION",
389 ServiceError::RateLimitExceeded(_) => "RATE_LIMIT_EXCEEDED",
390 ServiceError::Conflict(_) => "CONFLICT",
391 ServiceError::Cache { .. } => "CACHE_ERROR",
392 ServiceError::Timeout { .. } => "TIMEOUT",
393 ServiceError::ResourceExhausted { .. } => "RESOURCE_EXHAUSTED",
394 _ => "INTERNAL_ERROR",
395 };
396
397 let field = match &self {
399 ServiceError::InvalidInput { field, .. } => field.clone(),
400 _ => None,
401 };
402
403 let retryable = self.is_retryable();
404 let severe = self.is_severe();
405
406 let mut error = async_graphql::Error::new(message);
408 error = error.extend_with(|_, e| {
409 e.set("code", code);
410 e.set("retryable", retryable);
411 e.set("severe", severe);
412
413 if let Some(field_name) = field {
414 e.set("field", field_name);
415 }
416
417 if let ServiceError::Timeout { operation, timeout_ms } = &self {
419 e.set("operation", operation.clone());
420 e.set("timeout_ms", *timeout_ms);
421 }
422
423 if let ServiceError::ResourceExhausted { resource, .. } = &self {
425 e.set("resource", resource.clone());
426 }
427 });
428
429 error
430 }
431}
432
433#[cfg(feature = "http-errors")]
438impl From<ServiceError> for http::StatusCode {
439 fn from(error: ServiceError) -> Self {
440 match error {
441 ServiceError::NotFound { .. } => http::StatusCode::NOT_FOUND,
442 ServiceError::InvalidInput { .. } => http::StatusCode::BAD_REQUEST,
443 ServiceError::ValidationErrors(_) => http::StatusCode::BAD_REQUEST,
444 ServiceError::Unauthenticated(_) => http::StatusCode::UNAUTHORIZED,
445 ServiceError::PermissionDenied(_) => http::StatusCode::FORBIDDEN,
446 ServiceError::BusinessRule(_) => http::StatusCode::UNPROCESSABLE_ENTITY,
447 ServiceError::RateLimitExceeded(_) => http::StatusCode::TOO_MANY_REQUESTS,
448 ServiceError::Conflict(_) => http::StatusCode::CONFLICT,
449 ServiceError::Timeout { .. } => http::StatusCode::GATEWAY_TIMEOUT,
450 ServiceError::ResourceExhausted { .. } => http::StatusCode::SERVICE_UNAVAILABLE,
451 ServiceError::Database { .. }
452 | ServiceError::Cache { .. }
453 | ServiceError::ExternalService { .. }
454 | ServiceError::Configuration(_)
455 | ServiceError::Internal { .. } => http::StatusCode::INTERNAL_SERVER_ERROR,
456 }
457 }
458}
459
460#[cfg(feature = "http-errors")]
461impl axum::response::IntoResponse for ServiceError {
462 fn into_response(self) -> axum::response::Response {
463 let status = match &self {
465 ServiceError::NotFound { .. } => http::StatusCode::NOT_FOUND,
466 ServiceError::InvalidInput { .. } => http::StatusCode::BAD_REQUEST,
467 ServiceError::ValidationErrors(_) => http::StatusCode::BAD_REQUEST,
468 ServiceError::Unauthenticated(_) => http::StatusCode::UNAUTHORIZED,
469 ServiceError::PermissionDenied(_) => http::StatusCode::FORBIDDEN,
470 ServiceError::BusinessRule(_) => http::StatusCode::UNPROCESSABLE_ENTITY,
471 ServiceError::RateLimitExceeded(_) => http::StatusCode::TOO_MANY_REQUESTS,
472 ServiceError::Conflict(_) => http::StatusCode::CONFLICT,
473 ServiceError::Timeout { .. } => http::StatusCode::GATEWAY_TIMEOUT,
474 ServiceError::ResourceExhausted { .. } => http::StatusCode::SERVICE_UNAVAILABLE,
475 ServiceError::Database { .. }
476 | ServiceError::Cache { .. }
477 | ServiceError::ExternalService { .. }
478 | ServiceError::Configuration(_)
479 | ServiceError::Internal { .. } => http::StatusCode::INTERNAL_SERVER_ERROR,
480 };
481
482 #[cfg(feature = "serialization")]
483 {
484 let body = ErrorResponse::from(self);
485 (status, axum::Json(body)).into_response()
486 }
487
488 #[cfg(not(feature = "serialization"))]
489 {
490 (status, self.to_string()).into_response()
491 }
492 }
493}
494
495#[cfg(feature = "database")]
500impl From<sqlx::Error> for ServiceError {
501 fn from(error: sqlx::Error) -> Self {
502 match error {
503 sqlx::Error::RowNotFound => {
504 Self::not_found("record", "unknown")
505 }
506 sqlx::Error::Database(db_err) => {
507 if let Some(constraint) = db_err.constraint() {
509 Self::Conflict(format!("Constraint violation: {}", constraint))
510 } else {
511 Self::database_msg(db_err.message())
512 }
513 }
514 _ => Self::database("Database operation failed", error),
515 }
516 }
517}
518
519#[cfg(feature = "database")]
520impl From<redis::RedisError> for ServiceError {
521 fn from(error: redis::RedisError) -> Self {
522 Self::database("Redis operation failed", error)
523 }
524}
525
526#[cfg(feature = "serialization")]
531impl From<serde_json::Error> for ServiceError {
532 fn from(error: serde_json::Error) -> Self {
533 Self::InvalidInput {
534 message: format!("JSON parsing failed: {}", error),
535 field: None,
536 }
537 }
538}
539
540impl From<url::ParseError> for ServiceError {
541 fn from(error: url::ParseError) -> Self {
542 Self::InvalidInput {
543 message: format!("URL parsing failed: {}", error),
544 field: None,
545 }
546 }
547}
548
549impl From<std::io::Error> for ServiceError {
550 fn from(error: std::io::Error) -> Self {
551 match error.kind() {
552 std::io::ErrorKind::NotFound => Self::NotFound {
553 resource_type: "file".to_string(),
554 identifier: error.to_string(),
555 },
556 std::io::ErrorKind::PermissionDenied => {
557 Self::PermissionDenied(format!("I/O permission denied: {}", error))
558 }
559 std::io::ErrorKind::TimedOut => Self::timeout("I/O operation", 0),
560 _ => Self::internal("I/O error", error),
561 }
562 }
563}
564
565#[cfg(feature = "logging")]
570pub fn log_error(error: &ServiceError, context: &str) {
571 if error.is_severe() {
572 tracing::error!(
573 error = %error,
574 context = context,
575 "Service error occurred"
576 );
577 } else {
578 tracing::warn!(
579 error = %error,
580 context = context,
581 "Service error occurred"
582 );
583 }
584}
585
586#[cfg(test)]
591mod tests {
592 use super::*;
593 use uuid::Uuid;
594
595 #[test]
596 fn test_error_creation() {
597 let err = ServiceError::not_found("User", Uuid::new_v4());
598 assert!(matches!(err, ServiceError::NotFound { .. }));
599
600 let err = ServiceError::invalid_input("Invalid email");
601 assert!(matches!(err, ServiceError::InvalidInput { .. }));
602
603 let err = ServiceError::invalid_field("email", "Must be valid");
604 assert!(matches!(err, ServiceError::InvalidInput { field: Some(_), .. }));
605 }
606
607 #[test]
608 fn test_error_retryable() {
609 assert!(ServiceError::database_msg("Connection failed").is_retryable());
610 assert!(ServiceError::RateLimitExceeded("Too many requests".to_string()).is_retryable());
611 assert!(!ServiceError::not_found("User", "123").is_retryable());
612 assert!(!ServiceError::InvalidInput {
613 message: "Bad input".to_string(),
614 field: None
615 }
616 .is_retryable());
617 }
618
619 #[test]
620 fn test_error_severity() {
621 assert!(ServiceError::database_msg("Connection failed").is_severe());
622 assert!(ServiceError::internal_msg("Panic").is_severe());
623 assert!(!ServiceError::not_found("User", "123").is_severe());
624 assert!(!ServiceError::InvalidInput {
625 message: "Bad input".to_string(),
626 field: None
627 }
628 .is_severe());
629 }
630
631 #[cfg(feature = "serialization")]
632 #[test]
633 fn test_error_serialization() {
634 let err = ServiceError::not_found("User", "123");
635 let response = ErrorResponse::from(err);
636 assert_eq!(response.error, "NOT_FOUND");
637 assert!(response.message.contains("User"));
638 }
639
640 #[cfg(feature = "http-errors")]
641 #[test]
642 fn test_http_status_conversion() {
643 use http::StatusCode;
644
645 assert_eq!(
646 StatusCode::from(ServiceError::not_found("User", "123")),
647 StatusCode::NOT_FOUND
648 );
649 assert_eq!(
650 StatusCode::from(ServiceError::invalid_input("Bad")),
651 StatusCode::BAD_REQUEST
652 );
653 assert_eq!(
654 StatusCode::from(ServiceError::Unauthenticated("Login required".to_string())),
655 StatusCode::UNAUTHORIZED
656 );
657 assert_eq!(
658 StatusCode::from(ServiceError::timeout("database query", 5000)),
659 StatusCode::GATEWAY_TIMEOUT
660 );
661 assert_eq!(
662 StatusCode::from(ServiceError::resource_exhausted("memory", "Out of memory")),
663 StatusCode::SERVICE_UNAVAILABLE
664 );
665 }
666
667 #[test]
668 fn test_new_error_variants() {
669 let cache_err = ServiceError::cache_msg("Cache miss");
670 assert!(matches!(cache_err, ServiceError::Cache { .. }));
671 assert!(cache_err.is_retryable());
672
673 let timeout_err = ServiceError::timeout("API call", 3000);
674 assert!(matches!(timeout_err, ServiceError::Timeout { .. }));
675 assert!(timeout_err.is_retryable());
676 assert!(timeout_err.to_string().contains("3000ms"));
677
678 let exhausted_err = ServiceError::resource_exhausted("connections", "Pool exhausted");
679 assert!(matches!(exhausted_err, ServiceError::ResourceExhausted { .. }));
680 assert!(exhausted_err.is_retryable());
681 assert!(exhausted_err.is_severe());
682 }
683
684 #[cfg(feature = "context")]
685 #[test]
686 fn test_context_method() {
687 let err = ServiceError::not_found("User", "123");
688 let with_context = err.context("Failed to load user profile");
689
690 assert!(matches!(with_context, ServiceError::Internal { .. }));
691 assert!(with_context.to_string().contains("Failed to load user profile"));
692 assert!(with_context.to_string().contains("User"));
693 }
694
695 #[cfg(feature = "graphql")]
696 #[test]
697 fn test_graphql_error_conversion() {
698 let timeout_err = ServiceError::timeout("database query", 5000);
699 let graphql_err = timeout_err.into_graphql_error();
700
701 assert!(graphql_err.message.contains("database query"));
704 }
705
706 #[cfg(feature = "serialization")]
707 #[test]
708 fn test_json_error_conversion() {
709 let json_str = r#"{"invalid": json"#;
710 let result: std::result::Result<serde_json::Value, serde_json::Error> = serde_json::from_str(json_str);
711
712 if let Err(json_err) = result {
713 let service_err = ServiceError::from(json_err);
714 assert!(matches!(service_err, ServiceError::InvalidInput { .. }));
715 assert!(service_err.to_string().contains("JSON parsing failed"));
716 }
717 }
718
719 #[test]
720 fn test_url_error_conversion() {
721 let invalid_url = "not a valid url";
722 let result = url::Url::parse(invalid_url);
723
724 if let Err(url_err) = result {
725 let service_err = ServiceError::from(url_err);
726 assert!(matches!(service_err, ServiceError::InvalidInput { .. }));
727 assert!(service_err.to_string().contains("URL parsing failed"));
728 }
729 }
730}