Skip to main content

pleme_error/
lib.rs

1//! # pleme-error
2//!
3//! Unified error handling library for Pleme platform services.
4//!
5//! ## Philosophy
6//!
7//! This library implements Railway-Oriented Programming (Scott Wlaschin) principles:
8//! - Errors are first-class citizens
9//! - Railway tracks: success path vs error path
10//! - Composable error handling
11//!
12//! ## Usage
13//!
14//! ```rust
15//! use pleme_error::{ServiceError, Result};
16//!
17//! fn get_user(id: &str) -> Result<User> {
18//!     let user = db.find(id)
19//!         .map_err(|e| ServiceError::database("User not found", e))?;
20//!     Ok(user)
21//! }
22//! ```
23//!
24//! ## Features
25//!
26//! - `context` - Error context and chaining with anyhow
27//! - `graphql` - GraphQL error conversion for async-graphql
28//! - `http-errors` - HTTP status code conversion for Axum
29//! - `logging` - Structured error logging with tracing
30//! - `database` - Database error conversions (sqlx, Redis)
31//! - `web` - Full web stack (graphql + http-errors + logging)
32//! - `full` - All features enabled
33
34use std::fmt;
35use thiserror::Error;
36
37/// Unified error type for Pleme services
38///
39/// Follows Railway-Oriented Programming pattern where errors flow
40/// through a separate "error track" from the success track.
41#[derive(Error, Debug)]
42pub enum ServiceError {
43    /// Database operation failed
44    #[error("Database error: {message}")]
45    Database {
46        message: String,
47        #[source]
48        source: Option<Box<dyn std::error::Error + Send + Sync>>,
49    },
50
51    /// Resource not found
52    #[error("Not found: {resource_type} with {identifier}")]
53    NotFound {
54        resource_type: String,
55        identifier: String,
56    },
57
58    /// Invalid input or validation error
59    #[error("Invalid input: {message}")]
60    InvalidInput {
61        message: String,
62        field: Option<String>,
63    },
64
65    /// Authentication required
66    #[error("Authentication required: {0}")]
67    Unauthenticated(String),
68
69    /// Permission denied
70    #[error("Permission denied: {0}")]
71    PermissionDenied(String),
72
73    /// Business logic constraint violated
74    #[error("Business rule violation: {0}")]
75    BusinessRule(String),
76
77    /// External service error (e.g., payment gateway, email service)
78    #[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    /// Configuration error
87    #[error("Configuration error: {0}")]
88    Configuration(String),
89
90    /// Rate limit exceeded
91    #[error("Rate limit exceeded: {0}")]
92    RateLimitExceeded(String),
93
94    /// Conflict (e.g., duplicate resource)
95    #[error("Conflict: {0}")]
96    Conflict(String),
97
98    /// Cache operation failed
99    #[error("Cache error: {message}")]
100    Cache {
101        message: String,
102        #[source]
103        source: Option<Box<dyn std::error::Error + Send + Sync>>,
104    },
105
106    /// Operation timeout
107    #[error("Timeout: {operation} exceeded {timeout_ms}ms")]
108    Timeout {
109        operation: String,
110        timeout_ms: u64,
111    },
112
113    /// Resource exhausted (memory, disk, connections, etc.)
114    #[error("Resource exhausted: {resource} - {message}")]
115    ResourceExhausted {
116        resource: String,
117        message: String,
118    },
119
120    /// Validation errors for multiple fields
121    #[error("Validation failed: {0:?}")]
122    ValidationErrors(std::collections::HashMap<String, Vec<String>>),
123
124    /// Internal server error (catch-all)
125    #[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    /// Create a database error with context
135    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    /// Create a database error without source
146    pub fn database_msg(message: impl Into<String>) -> Self {
147        Self::Database {
148            message: message.into(),
149            source: None,
150        }
151    }
152
153    /// Create a not found error
154    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    /// Create an invalid input error
162    pub fn invalid_input(message: impl Into<String>) -> Self {
163        Self::InvalidInput {
164            message: message.into(),
165            field: None,
166        }
167    }
168
169    /// Create an invalid input error with field name
170    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    /// Create an external service error
178    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    /// Create an internal error with context
190    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    /// Create an internal error without source
201    pub fn internal_msg(message: impl Into<String>) -> Self {
202        Self::Internal {
203            message: message.into(),
204            source: None,
205        }
206    }
207
208    /// Create a cache error with context
209    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    /// Create a cache error without source
220    pub fn cache_msg(message: impl Into<String>) -> Self {
221        Self::Cache {
222            message: message.into(),
223            source: None,
224        }
225    }
226
227    /// Create a timeout error
228    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    /// Create a resource exhausted error
236    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    /// Add context to an error (for chaining)
244    ///
245    /// This method allows Railway-Oriented Programming style error chaining:
246    /// ```rust
247    /// load_user(id)
248    ///     .context("Failed to load user profile")?;
249    /// ```
250    #[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    /// Check if error is retryable (for exponential backoff)
259    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    /// Check if error should be logged at error level
273    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
284/// Result type alias for Railway-Oriented Programming
285///
286/// Success track: Ok(T)
287/// Error track: Err(ServiceError)
288pub type Result<T> = std::result::Result<T, ServiceError>;
289
290// ============================================================================
291// Optional: anyhow context support
292// ============================================================================
293
294#[cfg(feature = "context")]
295pub use anyhow::Context;
296
297// Field validation helpers
298pub 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// ============================================================================
309// Optional: Serialization support
310// ============================================================================
311
312#[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// ============================================================================
362// Optional: GraphQL error conversion
363// ============================================================================
364
365#[cfg(feature = "graphql")]
366use async_graphql::ErrorExtensions;
367
368#[cfg(feature = "graphql")]
369impl ServiceError {
370    /// Convert to GraphQL error with structured extensions
371    ///
372    /// Provides rich error information for GraphQL clients:
373    /// - error code for client-side handling
374    /// - retryable flag for retry logic
375    /// - severe flag for logging decisions
376    /// - field information for validation errors
377    /// - operation-specific metadata
378    pub fn into_graphql_error(self) -> async_graphql::Error {
379        let message = self.to_string();
380
381        // Add error code for client-side handling
382        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        // Extract field information if present
398        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        // Build structured error with extensions
407        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            // Add timeout details for timeout errors
418            if let ServiceError::Timeout { operation, timeout_ms } = &self {
419                e.set("operation", operation.clone());
420                e.set("timeout_ms", *timeout_ms);
421            }
422
423            // Add resource details for resource exhausted errors
424            if let ServiceError::ResourceExhausted { resource, .. } = &self {
425                e.set("resource", resource.clone());
426            }
427        });
428
429        error
430    }
431}
432
433// ============================================================================
434// Optional: HTTP error conversion (Axum)
435// ============================================================================
436
437#[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        // Extract status code before consuming self
464        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// ============================================================================
496// Optional: Database error conversions
497// ============================================================================
498
499#[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                // Check for unique constraint violations
508                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// ============================================================================
527// Optional: Common error type conversions
528// ============================================================================
529
530#[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// ============================================================================
566// Optional: Structured logging
567// ============================================================================
568
569#[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// ============================================================================
587// Tests
588// ============================================================================
589
590#[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        // async-graphql doesn't provide direct access to extensions in tests,
702        // but we can verify the error message is present
703        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}