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}