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