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}