Skip to main content

fraiseql_core/
error.rs

1//! Error types for `FraiseQL` core.
2//!
3//! This module provides language-agnostic error types that can be
4//! converted to Python exceptions, `JavaScript` errors, etc. by binding layers.
5//!
6//! # Error Hierarchy
7//!
8//! ```text
9//! FraiseQLError
10//! ├── Parse           - GraphQL parsing errors
11//! ├── Validation      - Schema/input validation errors
12//! ├── Database        - PostgreSQL errors
13//! ├── Authorization   - Permission/RBAC errors
14//! ├── Configuration   - Config/setup errors
15//! ├── Timeout         - Operation timeout
16//! ├── NotFound        - Resource not found
17//! ├── Conflict        - Concurrent modification
18//! └── Internal        - Unexpected internal errors
19//! ```
20
21use thiserror::Error;
22
23/// Result type alias for `FraiseQL` operations.
24pub type Result<T> = std::result::Result<T, FraiseQLError>;
25
26/// Main error type for `FraiseQL` operations.
27///
28/// All errors in the core library are converted to this type.
29/// Language bindings convert this to their native error types.
30///
31/// # Stability
32///
33/// This enum is marked `#[non_exhaustive]` to allow adding new error variants
34/// in future minor versions without breaking backward compatibility.
35#[derive(Error, Debug)]
36#[non_exhaustive]
37pub enum FraiseQLError {
38    // ========================================================================
39    // GraphQL Errors
40    // ========================================================================
41    /// GraphQL parsing error.
42    ///
43    /// Returned when the GraphQL query string cannot be parsed.
44    #[error("Parse error at {location}: {message}")]
45    Parse {
46        /// Error message describing the parse failure.
47        message:  String,
48        /// Location in the query where the error occurred.
49        location: String,
50    },
51
52    /// GraphQL validation error.
53    ///
54    /// Returned when a query is syntactically valid but semantically invalid.
55    #[error("Validation error: {message}")]
56    Validation {
57        /// Error message describing the validation failure.
58        message: String,
59        /// Path to the field with the error (e.g., "user.posts.0.title").
60        path:    Option<String>,
61    },
62
63    /// Unknown field error.
64    ///
65    /// Returned when a query references a field that doesn't exist in the schema.
66    #[error("Unknown field '{field}' on type '{type_name}'")]
67    UnknownField {
68        /// The field name that was not found.
69        field:     String,
70        /// The type on which the field was queried.
71        type_name: String,
72    },
73
74    /// Unknown type error.
75    ///
76    /// Returned when a query references a type that doesn't exist in the schema.
77    #[error("Unknown type '{type_name}'")]
78    UnknownType {
79        /// The type name that was not found.
80        type_name: String,
81    },
82
83    // ========================================================================
84    // Database Errors
85    // ========================================================================
86    /// Database operation error.
87    ///
88    /// Wraps errors from `PostgreSQL` operations.
89    #[error("Database error: {message}")]
90    Database {
91        /// Error message from the database.
92        message:   String,
93        /// SQL state code if available (e.g., "23505" for unique violation).
94        sql_state: Option<String>,
95    },
96
97    /// Connection pool error.
98    ///
99    /// Returned when the database connection pool is exhausted or unavailable.
100    #[error("Connection pool error: {message}")]
101    ConnectionPool {
102        /// Error message.
103        message: String,
104    },
105
106    /// Query timeout error.
107    ///
108    /// Returned when a database query exceeds the configured timeout.
109    #[error("Query timeout after {timeout_ms}ms")]
110    Timeout {
111        /// Timeout duration in milliseconds.
112        timeout_ms: u64,
113        /// The query that timed out (truncated if too long).
114        query:      Option<String>,
115    },
116
117    /// Query cancellation error.
118    ///
119    /// Returned when a query execution is cancelled via a cancellation token
120    /// or similar mechanism (e.g., client disconnection, explicit user request).
121    #[error("Query cancelled: {reason}")]
122    Cancelled {
123        /// Query identifier for tracking/logging.
124        query_id: String,
125        /// Reason for cancellation (e.g., "user cancelled", "connection closed").
126        reason:   String,
127    },
128
129    // ========================================================================
130    // Authorization Errors
131    // ========================================================================
132    /// Authorization error.
133    ///
134    /// Returned when the user doesn't have permission for an operation.
135    #[error("Authorization error: {message}")]
136    Authorization {
137        /// Error message.
138        message:  String,
139        /// The action that was denied (e.g., "read", "write", "delete").
140        action:   Option<String>,
141        /// The resource that was being accessed.
142        resource: Option<String>,
143    },
144
145    /// Authentication error.
146    ///
147    /// Returned when authentication fails (invalid token, expired, etc.).
148    #[error("Authentication error: {message}")]
149    Authentication {
150        /// Error message.
151        message: String,
152    },
153
154    /// Rate limiting error.
155    ///
156    /// Returned when a request is rate limited due to too many errors.
157    #[error("Rate limit exceeded: {message}")]
158    RateLimited {
159        /// Error message.
160        message:          String,
161        /// Number of seconds to wait before retrying.
162        retry_after_secs: u64,
163    },
164
165    // ========================================================================
166    // Resource Errors
167    // ========================================================================
168    /// Resource not found error.
169    ///
170    /// Returned when a requested resource doesn't exist.
171    #[error("{resource_type} not found: {identifier}")]
172    NotFound {
173        /// Type of resource (e.g., "User", "Post").
174        resource_type: String,
175        /// Identifier that was looked up.
176        identifier:    String,
177    },
178
179    /// Conflict error.
180    ///
181    /// Returned when an operation would conflict with existing data.
182    #[error("Conflict: {message}")]
183    Conflict {
184        /// Error message.
185        message: String,
186    },
187
188    // ========================================================================
189    // Configuration Errors
190    // ========================================================================
191    /// Configuration error.
192    ///
193    /// Returned when configuration is invalid or missing.
194    #[error("Configuration error: {message}")]
195    Configuration {
196        /// Error message.
197        message: String,
198    },
199
200    // ========================================================================
201    // Internal Errors
202    // ========================================================================
203    /// Internal error.
204    ///
205    /// Returned for unexpected internal errors. Should be rare.
206    #[error("Internal error: {message}")]
207    Internal {
208        /// Error message.
209        message: String,
210        /// Optional source error for debugging.
211        #[source]
212        source:  Option<Box<dyn std::error::Error + Send + Sync>>,
213    },
214}
215
216impl FraiseQLError {
217    // ========================================================================
218    // Constructor helpers
219    // ========================================================================
220
221    /// Create a parse error.
222    #[must_use]
223    pub fn parse(message: impl Into<String>) -> Self {
224        Self::Parse {
225            message:  message.into(),
226            location: "unknown".to_string(),
227        }
228    }
229
230    /// Create a parse error with location.
231    #[must_use]
232    pub fn parse_at(message: impl Into<String>, location: impl Into<String>) -> Self {
233        Self::Parse {
234            message:  message.into(),
235            location: location.into(),
236        }
237    }
238
239    /// Create a validation error.
240    #[must_use]
241    pub fn validation(message: impl Into<String>) -> Self {
242        Self::Validation {
243            message: message.into(),
244            path:    None,
245        }
246    }
247
248    /// Create a validation error with path.
249    #[must_use]
250    pub fn validation_at(message: impl Into<String>, path: impl Into<String>) -> Self {
251        Self::Validation {
252            message: message.into(),
253            path:    Some(path.into()),
254        }
255    }
256
257    /// Create a database error.
258    #[must_use]
259    pub fn database(message: impl Into<String>) -> Self {
260        Self::Database {
261            message:   message.into(),
262            sql_state: None,
263        }
264    }
265
266    /// Create an authorization error.
267    #[must_use]
268    pub fn unauthorized(message: impl Into<String>) -> Self {
269        Self::Authorization {
270            message:  message.into(),
271            action:   None,
272            resource: None,
273        }
274    }
275
276    /// Create a not found error.
277    #[must_use]
278    pub fn not_found(resource_type: impl Into<String>, identifier: impl Into<String>) -> Self {
279        Self::NotFound {
280            resource_type: resource_type.into(),
281            identifier:    identifier.into(),
282        }
283    }
284
285    /// Create a configuration error.
286    #[must_use]
287    pub fn config(message: impl Into<String>) -> Self {
288        Self::Configuration {
289            message: message.into(),
290        }
291    }
292
293    /// Create an internal error.
294    #[must_use]
295    pub fn internal(message: impl Into<String>) -> Self {
296        Self::Internal {
297            message: message.into(),
298            source:  None,
299        }
300    }
301
302    /// Create a cancellation error.
303    #[must_use]
304    pub fn cancelled(query_id: impl Into<String>, reason: impl Into<String>) -> Self {
305        Self::Cancelled {
306            query_id: query_id.into(),
307            reason:   reason.into(),
308        }
309    }
310
311    // ========================================================================
312    // Error classification
313    // ========================================================================
314
315    /// Check if this is a client error (4xx equivalent).
316    #[must_use]
317    pub const fn is_client_error(&self) -> bool {
318        matches!(
319            self,
320            Self::Parse { .. }
321                | Self::Validation { .. }
322                | Self::UnknownField { .. }
323                | Self::UnknownType { .. }
324                | Self::Authorization { .. }
325                | Self::Authentication { .. }
326                | Self::NotFound { .. }
327                | Self::Conflict { .. }
328                | Self::RateLimited { .. }
329        )
330    }
331
332    /// Check if this is a server error (5xx equivalent).
333    #[must_use]
334    pub const fn is_server_error(&self) -> bool {
335        matches!(
336            self,
337            Self::Database { .. }
338                | Self::ConnectionPool { .. }
339                | Self::Timeout { .. }
340                | Self::Cancelled { .. }
341                | Self::Configuration { .. }
342                | Self::Internal { .. }
343        )
344    }
345
346    /// Check if this error is retryable.
347    #[must_use]
348    pub const fn is_retryable(&self) -> bool {
349        matches!(
350            self,
351            Self::ConnectionPool { .. } | Self::Timeout { .. } | Self::Cancelled { .. }
352        )
353    }
354
355    /// Get HTTP status code equivalent.
356    #[must_use]
357    pub const fn status_code(&self) -> u16 {
358        match self {
359            Self::Parse { .. }
360            | Self::Validation { .. }
361            | Self::UnknownField { .. }
362            | Self::UnknownType { .. } => 400,
363            Self::Authentication { .. } => 401,
364            Self::Authorization { .. } => 403,
365            Self::NotFound { .. } => 404,
366            Self::Conflict { .. } => 409,
367            Self::RateLimited { .. } => 429,
368            Self::Timeout { .. } | Self::Cancelled { .. } => 408,
369            Self::Database { .. }
370            | Self::ConnectionPool { .. }
371            | Self::Configuration { .. }
372            | Self::Internal { .. } => 500,
373        }
374    }
375
376    /// Get error code for GraphQL response.
377    #[must_use]
378    pub const fn error_code(&self) -> &'static str {
379        match self {
380            Self::Parse { .. } => "GRAPHQL_PARSE_FAILED",
381            Self::Validation { .. } => "GRAPHQL_VALIDATION_FAILED",
382            Self::UnknownField { .. } => "UNKNOWN_FIELD",
383            Self::UnknownType { .. } => "UNKNOWN_TYPE",
384            Self::Database { .. } => "DATABASE_ERROR",
385            Self::ConnectionPool { .. } => "CONNECTION_POOL_ERROR",
386            Self::Timeout { .. } => "TIMEOUT",
387            Self::Cancelled { .. } => "CANCELLED",
388            Self::Authorization { .. } => "FORBIDDEN",
389            Self::Authentication { .. } => "UNAUTHENTICATED",
390            Self::RateLimited { .. } => "RATE_LIMITED",
391            Self::NotFound { .. } => "NOT_FOUND",
392            Self::Conflict { .. } => "CONFLICT",
393            Self::Configuration { .. } => "CONFIGURATION_ERROR",
394            Self::Internal { .. } => "INTERNAL_SERVER_ERROR",
395        }
396    }
397}
398
399// ============================================================================
400// Conversions from other error types
401// ============================================================================
402
403impl From<serde_json::Error> for FraiseQLError {
404    fn from(e: serde_json::Error) -> Self {
405        Self::Parse {
406            message:  e.to_string(),
407            location: format!("line {}, column {}", e.line(), e.column()),
408        }
409    }
410}
411
412impl From<std::io::Error> for FraiseQLError {
413    fn from(e: std::io::Error) -> Self {
414        Self::Internal {
415            message: format!("I/O error: {e}"),
416            source:  Some(Box::new(e)),
417        }
418    }
419}
420
421impl From<std::env::VarError> for FraiseQLError {
422    fn from(e: std::env::VarError) -> Self {
423        Self::Configuration {
424            message: format!("Environment variable error: {e}"),
425        }
426    }
427}
428
429// ============================================================================
430// Error context extension trait
431// ============================================================================
432
433/// Extension trait for adding context to errors.
434///
435/// Provides methods to attach contextual information to errors, making debugging easier
436/// and providing better error messages to users.
437///
438/// # Usage Examples
439///
440/// **Adding static context to an error:**
441///
442/// ```ignore
443/// use fraiseql_core::error::ErrorContext;
444///
445/// fn load_schema(path: &str) -> Result<String> {
446///     std::fs::read_to_string(path)
447///         .map_err(|e| e.into())
448///         .context(format!("Failed to load schema from {}", path))
449/// }
450/// ```
451///
452/// **Adding lazy context (computed only on error):**
453///
454/// ```ignore
455/// use fraiseql_core::error::ErrorContext;
456///
457/// fn execute_query(query: &str) -> Result<Vec<()>> {
458///     // ... query execution ...
459///     Ok(vec![])
460///         .with_context(|| format!("Query execution failed for query: {}", query))
461/// }
462/// ```
463pub trait ErrorContext<T> {
464    /// Add context to an error.
465    ///
466    /// Prepends a context message to the error. Useful for providing operation-specific
467    /// information about where/why an error occurred.
468    ///
469    /// # Arguments
470    ///
471    /// * `message` - Context message to prepend to the error
472    ///
473    /// # Errors
474    ///
475    /// Returns the error with additional context message prepended.
476    ///
477    /// # Example
478    ///
479    /// ```rust,no_run
480    /// use fraiseql_core::error::ErrorContext;
481    /// # use fraiseql_core::error::Result;
482    ///
483    /// # async fn example() -> Result<()> {
484    /// let result: Result<String> = Err(fraiseql_core::error::FraiseQLError::database("connection failed"));
485    /// result.context("while connecting to primary database")?;
486    /// # Ok(())
487    /// # }
488    /// ```
489    fn context(self, message: impl Into<String>) -> Result<T>;
490
491    /// Add context lazily (only computed on error).
492    ///
493    /// Similar to `context()`, but the message is only computed if an error actually occurs.
494    /// Useful when building the context message is expensive or requires runtime information.
495    ///
496    /// # Arguments
497    ///
498    /// * `f` - Closure that computes the context message on error
499    ///
500    /// # Errors
501    ///
502    /// Returns the error with additional context message prepended.
503    ///
504    /// # Example
505    ///
506    /// ```rust,no_run
507    /// use fraiseql_core::error::ErrorContext;
508    /// # use fraiseql_core::error::Result;
509    ///
510    /// # async fn example(rows: Vec<()>) -> Result<()> {
511    /// // The expensive string formatting only happens if the operation fails
512    /// let processed: Result<()> = Ok(());
513    /// processed.with_context(|| {
514    ///     format!("Failed to process {} rows", rows.len())
515    /// })?;
516    /// # Ok(())
517    /// # }
518    /// ```
519    fn with_context<F, M>(self, f: F) -> Result<T>
520    where
521        F: FnOnce() -> M,
522        M: Into<String>;
523}
524
525impl<T, E: Into<FraiseQLError>> ErrorContext<T> for std::result::Result<T, E> {
526    fn context(self, message: impl Into<String>) -> Result<T> {
527        self.map_err(|e| {
528            let inner = e.into();
529            FraiseQLError::Internal {
530                message: format!("{}: {inner}", message.into()),
531                source:  None,
532            }
533        })
534    }
535
536    fn with_context<F, M>(self, f: F) -> Result<T>
537    where
538        F: FnOnce() -> M,
539        M: Into<String>,
540    {
541        self.map_err(|e| {
542            let inner = e.into();
543            FraiseQLError::Internal {
544                message: format!("{}: {inner}", f().into()),
545                source:  None,
546            }
547        })
548    }
549}
550
551/// A validation error for a specific field in an input object.
552///
553/// Used to report validation failures with field-level granularity,
554/// including the field path, validation rule type, and error message.
555#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
556pub struct ValidationFieldError {
557    /// Path to the field that failed validation (e.g., "user.email", "addresses.0.zipcode").
558    pub field:     String,
559    /// Type of validation rule that failed (e.g., "pattern", "required", "range").
560    pub rule_type: String,
561    /// Human-readable error message explaining what went wrong.
562    pub message:   String,
563}
564
565impl ValidationFieldError {
566    /// Create a new validation field error.
567    #[must_use]
568    pub fn new(
569        field: impl Into<String>,
570        rule_type: impl Into<String>,
571        message: impl Into<String>,
572    ) -> Self {
573        Self {
574            field:     field.into(),
575            rule_type: rule_type.into(),
576            message:   message.into(),
577        }
578    }
579}
580
581impl std::fmt::Display for ValidationFieldError {
582    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
583        write!(f, "{} ({}): {}", self.field, self.rule_type, self.message)
584    }
585}
586
587// ============================================================================
588// Tests
589// ============================================================================
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594
595    #[test]
596    fn test_parse_error() {
597        let err = FraiseQLError::parse("unexpected token");
598        assert!(err.is_client_error());
599        assert!(!err.is_server_error());
600        assert_eq!(err.status_code(), 400);
601        assert_eq!(err.error_code(), "GRAPHQL_PARSE_FAILED");
602    }
603
604    #[test]
605    fn test_database_error() {
606        let err = FraiseQLError::database("connection refused");
607        assert!(!err.is_client_error());
608        assert!(err.is_server_error());
609        assert_eq!(err.status_code(), 500);
610    }
611
612    #[test]
613    fn test_not_found_error() {
614        let err = FraiseQLError::not_found("User", "123");
615        assert!(err.is_client_error());
616        assert_eq!(err.status_code(), 404);
617        assert_eq!(err.to_string(), "User not found: 123");
618    }
619
620    #[test]
621    fn test_retryable_errors() {
622        assert!(
623            FraiseQLError::ConnectionPool {
624                message: "timeout".to_string(),
625            }
626            .is_retryable()
627        );
628        assert!(
629            FraiseQLError::Timeout {
630                timeout_ms: 5000,
631                query:      None,
632            }
633            .is_retryable()
634        );
635        assert!(!FraiseQLError::parse("bad query").is_retryable());
636    }
637
638    #[test]
639    fn test_from_serde_error() {
640        let json_err = serde_json::from_str::<serde_json::Value>("not json").unwrap_err();
641        let err: FraiseQLError = json_err.into();
642        assert!(matches!(err, FraiseQLError::Parse { .. }));
643    }
644
645    #[test]
646    fn test_error_context() {
647        fn may_fail() -> std::result::Result<(), std::io::Error> {
648            Err(std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"))
649        }
650
651        let result = may_fail().context("failed to load config");
652        assert!(result.is_err());
653
654        let err = result.unwrap_err();
655        assert!(err.to_string().contains("failed to load config"));
656    }
657
658    #[test]
659    fn test_validation_field_error_creation() {
660        let field_err = ValidationFieldError::new("user.email", "pattern", "Invalid email format");
661        assert_eq!(field_err.field, "user.email");
662        assert_eq!(field_err.rule_type, "pattern");
663        assert_eq!(field_err.message, "Invalid email format");
664    }
665
666    #[test]
667    fn test_validation_field_error_display() {
668        let field_err =
669            ValidationFieldError::new("address.zipcode", "length", "Zipcode must be 5 digits");
670        let display = format!("{}", field_err);
671        assert_eq!(display, "address.zipcode (length): Zipcode must be 5 digits");
672    }
673
674    #[test]
675    fn test_validation_field_error_serialization() {
676        let field_err = ValidationFieldError::new("user.phone", "pattern", "Invalid phone number");
677        let json = serde_json::to_string(&field_err).expect("serialization failed");
678        let deserialized: ValidationFieldError =
679            serde_json::from_str(&json).expect("deserialization failed");
680        assert_eq!(deserialized.field, "user.phone");
681        assert_eq!(deserialized.rule_type, "pattern");
682        assert_eq!(deserialized.message, "Invalid phone number");
683    }
684}