fraiseql_error/core_error.rs
1//! Core error types for FraiseQL operations.
2//!
3//! This module provides the primary error enum `FraiseQLError` used throughout
4//! the FraiseQL compilation and execution pipeline.
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9/// Result type alias for FraiseQL operations.
10pub type Result<T> = std::result::Result<T, FraiseQLError>;
11
12/// Main error type for FraiseQL operations.
13///
14/// All errors in the core library are converted to this type.
15/// Language bindings convert this to their native error types.
16///
17/// # Error Categories
18///
19/// Errors are organized by domain:
20///
21/// ## GraphQL Errors
22/// - `Parse` — Malformed GraphQL syntax
23/// - `Validation` — Schema validation failures
24/// - `UnknownField` — Field doesn't exist on type
25/// - `UnknownType` — Type doesn't exist in schema
26///
27/// ## Database Errors
28/// - `Database` — PostgreSQL/MySQL/SQLite errors (includes SQL state code)
29/// - `ConnectionPool` — Connection pool exhausted or unavailable
30/// - `Timeout` — Query exceeded configured timeout
31/// - `Cancelled` — Query was cancelled by caller
32///
33/// ## Authorization/Security Errors
34/// - `Authorization` — User lacks permission for operation
35/// - `Authentication` — Invalid/expired JWT token
36/// - `RateLimited` — Too many requests (includes retry-after)
37///
38/// ## Resource Errors
39/// - `NotFound` — Resource doesn't exist (404)
40/// - `Conflict` — Operation would violate constraints (409)
41///
42/// ## Configuration Errors
43/// - `Configuration` — Invalid setup/configuration
44/// - `Unsupported` — Operation not supported by current database backend
45///
46/// ## Internal Errors
47/// - `Internal` — Unexpected internal failures
48///
49/// # Stability
50///
51/// This enum is marked `#[non_exhaustive]` to allow adding new error variants
52/// in future minor versions without breaking backward compatibility.
53///
54/// External `match` expressions must include a wildcard `_` arm:
55///
56/// ```rust
57/// use fraiseql_error::FraiseQLError;
58///
59/// fn describe(e: &FraiseQLError) -> &'static str {
60/// match e {
61/// FraiseQLError::Parse { .. } => "parse error",
62/// FraiseQLError::Validation { .. } => "validation error",
63/// _ => "other error", // required: FraiseQLError is #[non_exhaustive]
64/// }
65/// }
66/// ```
67///
68/// The following would **not** compile, because the `#[non_exhaustive]`
69/// attribute forces downstream crates to handle the possibility of new
70/// variants — a wildcard arm is required even when every currently-defined
71/// variant is enumerated:
72///
73/// ```compile_fail
74/// use fraiseql_error::FraiseQLError;
75///
76/// fn describe(e: &FraiseQLError) -> &'static str {
77/// // Missing wildcard arm: rejected by rustc even though it lists
78/// // a few real variants — the `#[non_exhaustive]` attribute makes
79/// // the enum effectively open from any downstream crate's point
80/// // of view.
81/// match e {
82/// FraiseQLError::Parse { .. } => "parse",
83/// FraiseQLError::Validation { .. } => "validation",
84/// FraiseQLError::Database { .. } => "database",
85/// }
86/// }
87/// ```
88#[derive(Error, Debug)]
89#[non_exhaustive]
90pub enum FraiseQLError {
91 // ========================================================================
92 // GraphQL Errors
93 // ========================================================================
94 /// GraphQL parsing error.
95 #[error("Parse error at {location}: {message}")]
96 Parse {
97 /// Error message describing the parse failure.
98 message: String,
99 /// Location in the query where the error occurred.
100 location: String,
101 },
102
103 /// GraphQL validation error.
104 #[error("Validation error: {message}")]
105 Validation {
106 /// Error message describing the validation failure.
107 message: String,
108 /// Path to the field with the error (e.g., "user.posts.0.title").
109 path: Option<String>,
110 },
111
112 /// Unknown field error.
113 #[error("Unknown field '{field}' on type '{type_name}'")]
114 UnknownField {
115 /// The field name that was not found.
116 field: String,
117 /// The type on which the field was queried.
118 type_name: String,
119 },
120
121 /// Unknown type error.
122 #[error("Unknown type '{type_name}'")]
123 UnknownType {
124 /// The type name that was not found.
125 type_name: String,
126 },
127
128 // ========================================================================
129 // Database Errors
130 // ========================================================================
131 /// Database operation error.
132 #[error("Database error: {message}")]
133 Database {
134 /// Error message from the database.
135 message: String,
136 /// SQL state code if available (e.g., "23505" for unique violation).
137 sql_state: Option<String>,
138 },
139
140 /// Connection pool error.
141 #[error("Connection pool error: {message}")]
142 ConnectionPool {
143 /// Error message.
144 message: String,
145 },
146
147 /// Query timeout error.
148 #[error("Query timeout after {timeout_ms}ms")]
149 Timeout {
150 /// Timeout duration in milliseconds.
151 timeout_ms: u64,
152 /// The query that timed out (truncated if too long).
153 query: Option<String>,
154 },
155
156 /// Query cancellation error.
157 #[error("Query cancelled: {reason}")]
158 Cancelled {
159 /// Query identifier for tracking/logging.
160 query_id: String,
161 /// Reason for cancellation.
162 reason: String,
163 },
164
165 // ========================================================================
166 // Authorization Errors
167 // ========================================================================
168 /// Authorization error.
169 #[error("Authorization error: {message}")]
170 Authorization {
171 /// Error message.
172 message: String,
173 /// The action that was denied.
174 action: Option<String>,
175 /// The resource that was being accessed.
176 resource: Option<String>,
177 },
178
179 /// Authentication error.
180 #[error("Authentication error: {message}")]
181 Authentication {
182 /// Error message.
183 message: String,
184 },
185
186 /// Rate limiting error.
187 #[error("Rate limit exceeded: {message}")]
188 RateLimited {
189 /// Error message.
190 message: String,
191 /// Number of seconds to wait before retrying.
192 retry_after_secs: u64,
193 },
194
195 /// Operation cost budget exceeded (#379).
196 ///
197 /// Distinct from [`RateLimited`](Self::RateLimited): a per-request cost
198 /// ceiling (`retry_after_secs: None`) is permanent for the operation —
199 /// retrying the same query cannot succeed — while an exhausted rolling
200 /// budget window (`Some`) is retryable once the window resets.
201 #[error("Operation cost exceeded: {message}")]
202 CostExceeded {
203 /// Error message.
204 message: String,
205 /// Estimated cost of the rejected operation.
206 cost: u64,
207 /// The budget the cost exceeded.
208 limit: u64,
209 /// Seconds until the budget window resets; `None` for a per-request
210 /// ceiling.
211 retry_after_secs: Option<u64>,
212 },
213
214 /// The bytes a read would deliver exceed `[validation] max_response_bytes`.
215 ///
216 /// Distinct from [`CostExceeded`](Self::CostExceeded), which scores what a
217 /// request *asks for* before the database runs. This one measures what came
218 /// back. A materialised read costs what its documents weigh, and that is not
219 /// recoverable from the request: the same query against the same view returns
220 /// a kilobyte for one tenant and a gigabyte for another.
221 ///
222 /// Permanent for the request as issued — retrying it cannot succeed — so it is
223 /// a 4xx with no `retry_after`. The caller narrows `?select=` or lowers its
224 /// page size.
225 #[error(
226 "Response exceeds the maximum of {limit} bytes: {bytes} bytes delivered ([validation] \
227 max_response_bytes)"
228 )]
229 ResponseTooLarge {
230 /// Bytes measured when the ceiling was crossed. This is the running total
231 /// at the moment of refusal, not the full size of the untruncated
232 /// response — a stream is cut as soon as it goes over rather than being
233 /// read to the end to find out how far over it was.
234 bytes: u64,
235 /// The configured ceiling.
236 limit: u64,
237 },
238
239 // ========================================================================
240 // Resource Errors
241 // ========================================================================
242 /// Resource not found error.
243 #[error("{resource_type} not found: {identifier}")]
244 NotFound {
245 /// Type of resource (e.g., "User", "Post").
246 resource_type: String,
247 /// Identifier that was looked up.
248 identifier: String,
249 },
250
251 /// Conflict error.
252 #[error("Conflict: {message}")]
253 Conflict {
254 /// Error message.
255 message: String,
256 },
257
258 // ========================================================================
259 // Configuration Errors
260 // ========================================================================
261 /// Configuration error.
262 #[error("Configuration error: {message}")]
263 Configuration {
264 /// Error message.
265 message: String,
266 },
267
268 /// Unsupported operation error.
269 #[error("Unsupported operation: {message}")]
270 Unsupported {
271 /// Error message describing what is not supported.
272 message: String,
273 },
274
275 /// The service is temporarily unavailable (e.g. tenant suspended).
276 ///
277 /// Maps to HTTP 503. `retry_after` is the number of seconds to wait, if known.
278 #[error("Service unavailable: {message}")]
279 ServiceUnavailable {
280 /// Human-readable reason for the unavailability.
281 message: String,
282 /// Number of seconds to wait before retrying, if known.
283 retry_after: Option<u64>,
284 },
285
286 // ========================================================================
287 // Domain Subsystem Errors (composed via `From` impls in subsystem crates)
288 // ========================================================================
289 /// An authentication or authorisation error originating from the auth subsystem.
290 ///
291 /// The boxed source is the subsystem-specific error type (e.g.
292 /// `fraiseql_auth::AuthError`). To preserve subsystem vocabulary while
293 /// keeping `fraiseql-error` a leaf crate, the boxed payload is
294 /// type-erased here; subsystem crates provide their own
295 /// `impl From<SubsystemError> for FraiseQLError` (the sqlx pattern).
296 ///
297 /// `#[source]` is explicit: `thiserror` 2.x does not auto-detect a single
298 /// tuple field as the source, and downstream chain-walkers (`tracing`,
299 /// `miette`, `anyhow`) rely on `Error::source()` returning the underlying
300 /// subsystem error rather than `None`.
301 ///
302 /// # Pattern-matching on the inner error
303 ///
304 /// Because the payload is boxed and `dyn`-erased, downstream `match`
305 /// statements cannot bind on subsystem variants directly. Recover the
306 /// concrete type via [`std::error::Error::source`] + `downcast_ref`:
307 ///
308 /// ```ignore
309 /// use std::error::Error;
310 /// use fraiseql_error::FraiseQLError;
311 /// use fraiseql_auth::AuthError;
312 ///
313 /// if let FraiseQLError::Auth(_) = &err {
314 /// if let Some(inner) = err.source().and_then(|s| s.downcast_ref::<AuthError>()) {
315 /// match inner {
316 /// AuthError::TokenExpired => {/* handle */},
317 /// _ => {},
318 /// }
319 /// }
320 /// }
321 /// ```
322 #[error("Auth error: {0}")]
323 Auth(#[source] Box<dyn std::error::Error + Send + Sync>),
324
325 /// A webhook-processing error originating from the webhook subsystem.
326 ///
327 /// `#[source]` is explicit for the same reason as [`Self::Auth`]; see
328 /// that variant for the `downcast_ref` recovery pattern on the boxed
329 /// `fraiseql_webhooks::WebhookError`.
330 #[error("Webhook error: {0}")]
331 Webhook(#[source] Box<dyn std::error::Error + Send + Sync>),
332
333 /// An observer subsystem error (event dispatch, action execution, retry exhaustion).
334 ///
335 /// `#[source]` is explicit for the same reason as [`Self::Auth`]; see
336 /// that variant for the `downcast_ref` recovery pattern on the boxed
337 /// `fraiseql_observers::ObserverError`.
338 #[error("Observer error: {0}")]
339 Observer(#[source] Box<dyn std::error::Error + Send + Sync>),
340
341 /// A file-handling error (size limit, unsupported type, virus scan, quota).
342 ///
343 /// Unlike `Auth`, `Webhook`, and `Observer`, the file-domain vocabulary
344 /// lives inside `fraiseql-error` itself ([`crate::FileError`]) because no
345 /// subsystem crate owns it — file operations are spread across
346 /// `fraiseql-storage` and `fraiseql-server/storage`.
347 #[error("File error: {0}")]
348 File(#[from] crate::FileError),
349
350 // ========================================================================
351 // Internal Errors
352 // ========================================================================
353 /// Internal error.
354 #[error("Internal error: {message}")]
355 Internal {
356 /// Error message.
357 message: String,
358 /// Optional source error for debugging.
359 #[source]
360 source: Option<Box<dyn std::error::Error + Send + Sync>>,
361 },
362}
363
364impl FraiseQLError {
365 /// Create a parse error.
366 #[must_use]
367 pub fn parse(message: impl Into<String>) -> Self {
368 Self::Parse {
369 message: message.into(),
370 location: "unknown".to_string(),
371 }
372 }
373
374 /// Create a parse error with location.
375 #[must_use]
376 pub fn parse_at(message: impl Into<String>, location: impl Into<String>) -> Self {
377 Self::Parse {
378 message: message.into(),
379 location: location.into(),
380 }
381 }
382
383 /// Create a validation error.
384 #[must_use]
385 pub fn validation(message: impl Into<String>) -> Self {
386 Self::Validation {
387 message: message.into(),
388 path: None,
389 }
390 }
391
392 /// Create a validation error with path.
393 #[must_use]
394 pub fn validation_at(message: impl Into<String>, path: impl Into<String>) -> Self {
395 Self::Validation {
396 message: message.into(),
397 path: Some(path.into()),
398 }
399 }
400
401 /// Create a database error.
402 #[must_use]
403 pub fn database(message: impl Into<String>) -> Self {
404 Self::Database {
405 message: message.into(),
406 sql_state: None,
407 }
408 }
409
410 /// Create an authorization error.
411 #[must_use]
412 pub fn unauthorized(message: impl Into<String>) -> Self {
413 Self::Authorization {
414 message: message.into(),
415 action: None,
416 resource: None,
417 }
418 }
419
420 /// Create a not found error.
421 #[must_use]
422 pub fn not_found(resource_type: impl Into<String>, identifier: impl Into<String>) -> Self {
423 Self::NotFound {
424 resource_type: resource_type.into(),
425 identifier: identifier.into(),
426 }
427 }
428
429 /// Create a configuration error.
430 #[must_use]
431 pub fn config(message: impl Into<String>) -> Self {
432 Self::Configuration {
433 message: message.into(),
434 }
435 }
436
437 /// Create an internal error.
438 #[must_use]
439 pub fn internal(message: impl Into<String>) -> Self {
440 Self::Internal {
441 message: message.into(),
442 source: None,
443 }
444 }
445
446 /// Create a cancellation error.
447 #[must_use]
448 pub fn cancelled(query_id: impl Into<String>, reason: impl Into<String>) -> Self {
449 Self::Cancelled {
450 query_id: query_id.into(),
451 reason: reason.into(),
452 }
453 }
454
455 /// Check if this is a client error (4xx equivalent).
456 ///
457 /// Derived from [`Self::status_code`] so the answer stays consistent
458 /// with the variant's actual HTTP routing (notably for `File(_)`, which
459 /// straddles 4xx and 5xx after F050).
460 #[must_use]
461 pub const fn is_client_error(&self) -> bool {
462 let code = self.status_code();
463 code >= 400 && code < 500
464 }
465
466 /// Check if this is a server error (5xx equivalent).
467 ///
468 /// Derived from [`Self::status_code`] for the same consistency reason
469 /// as [`Self::is_client_error`].
470 #[must_use]
471 pub const fn is_server_error(&self) -> bool {
472 let code = self.status_code();
473 code >= 500 && code < 600
474 }
475
476 /// Check if this error is retryable.
477 #[must_use]
478 pub const fn is_retryable(&self) -> bool {
479 matches!(
480 self,
481 Self::ConnectionPool { .. }
482 | Self::Timeout { .. }
483 | Self::Cancelled { .. }
484 | Self::ServiceUnavailable { .. }
485 )
486 }
487
488 /// Get HTTP status code equivalent.
489 ///
490 /// # Future variants
491 ///
492 /// `FraiseQLError` is `#[non_exhaustive]`. The trailing `_ => 500` arm
493 /// is a deliberate safety net so a new variant added without updating
494 /// this match still gets a defined (and *safe*) HTTP status — generic
495 /// 500 Internal Server Error — rather than leaking implementation
496 /// details to the client by returning the wrong code.
497 // Reason: the trailing wildcard arm intentionally duplicates the 500
498 // server-error arm above (silencing `match_same_arms`), and is currently
499 // unreachable within this crate because the match enumerates every
500 // existing variant (silencing `unreachable_patterns`). The duplication is
501 // the security guarantee: when a future variant is added to this
502 // `#[non_exhaustive]` enum, the wildcard becomes reachable and prevents a
503 // wrong-status leak. See docs/history/IMPROVEMENTS.md F055.
504 // Reason: deliberate duplicate arms — non_exhaustive forward-compat guard (F055), see above
505 #[allow(clippy::match_same_arms, unreachable_patterns)]
506 #[must_use]
507 pub const fn status_code(&self) -> u16 {
508 match self {
509 Self::Parse { .. }
510 | Self::Validation { .. }
511 | Self::UnknownField { .. }
512 | Self::UnknownType { .. }
513 | Self::Webhook(_) => 400,
514 // File is per-variant: validation failures stay 400, backend
515 // failures escalate to 5xx, NotFound → 404, PermissionDenied →
516 // 403 (matches the legacy `storage_error_response` routing of
517 // `FraiseQLError::Storage` which F050 replaces).
518 Self::File(e) => e.status_code(),
519 Self::Authentication { .. } | Self::Auth(_) => 401,
520 Self::Authorization { .. } => 403,
521 Self::NotFound { .. } => 404,
522 Self::Conflict { .. } => 409,
523 Self::RateLimited { .. } => 429,
524 // #1201: a cost ceiling is the caller asking for too much, never a
525 // server fault. Which 4xx depends on whether asking again can ever
526 // work — the distinction this variant's own doc draws: an exhausted
527 // rolling window resets (429, retryable), a per-request ceiling does
528 // not (400, permanent). Before this it matched no arm and fell to the
529 // `_ => 500` default, so every over-budget request was reported as a
530 // server error and every retry policy treated it as retryable.
531 Self::CostExceeded {
532 retry_after_secs: Some(_),
533 ..
534 } => 429,
535 Self::CostExceeded {
536 retry_after_secs: None,
537 ..
538 } => 400,
539 // The response the caller asked for is larger than the operator
540 // allows. 413 rather than 400 to match how this codebase already
541 // spells "what you asked for is too big": `sse_max_replay_events`
542 // answers `413 RESUME_TOO_FAR_BEHIND` for the same shape of refusal.
543 Self::ResponseTooLarge { .. } => 413,
544 Self::Timeout { .. } | Self::Cancelled { .. } => 408,
545 Self::Database { .. }
546 | Self::ConnectionPool { .. }
547 | Self::Configuration { .. }
548 | Self::Internal { .. }
549 | Self::Observer(_) => 500,
550 Self::Unsupported { .. } => 501,
551 Self::ServiceUnavailable { .. } => 503,
552 // SECURITY: any future variant defaults to 500 Internal Server
553 // Error until explicitly mapped, so we never accidentally return
554 // an inappropriate status (e.g. 200, 401) to clients.
555 _ => 500,
556 }
557 }
558
559 /// Get error code for GraphQL response.
560 ///
561 /// # Future variants
562 ///
563 /// `FraiseQLError` is `#[non_exhaustive]`. The trailing `_` arm returns
564 /// `"INTERNAL_SERVER_ERROR"` so a new variant added without updating
565 /// this match still receives a stable (if generic) error code.
566 // Reason: see `status_code` — same security-defence rationale; same two
567 // lints (`match_same_arms` for the duplicated arm body, `unreachable_patterns`
568 // because the in-crate match currently covers every variant).
569 #[allow(clippy::match_same_arms, unreachable_patterns)]
570 #[must_use]
571 pub const fn error_code(&self) -> &'static str {
572 match self {
573 Self::Parse { .. } => "GRAPHQL_PARSE_FAILED",
574 Self::Validation { .. } => "GRAPHQL_VALIDATION_FAILED",
575 Self::UnknownField { .. } => "UNKNOWN_FIELD",
576 Self::UnknownType { .. } => "UNKNOWN_TYPE",
577 Self::Database { .. } => "DATABASE_ERROR",
578 Self::ConnectionPool { .. } => "CONNECTION_POOL_ERROR",
579 Self::Timeout { .. } => "TIMEOUT",
580 Self::Cancelled { .. } => "CANCELLED",
581 Self::Authorization { .. } => "FORBIDDEN",
582 Self::Authentication { .. } => "UNAUTHENTICATED",
583 Self::Auth(_) => "AUTH_ERROR",
584 Self::Webhook(_) => "WEBHOOK_ERROR",
585 Self::Observer(_) => "OBSERVER_ERROR",
586 Self::File(_) => "FILE_ERROR",
587 Self::RateLimited { .. } => "RATE_LIMITED",
588 Self::NotFound { .. } => "NOT_FOUND",
589 Self::Conflict { .. } => "CONFLICT",
590 Self::Configuration { .. } => "CONFIGURATION_ERROR",
591 Self::Unsupported { .. } => "UNSUPPORTED_OPERATION",
592 Self::ServiceUnavailable { .. } => "SERVICE_UNAVAILABLE",
593 Self::Internal { .. } => "INTERNAL_SERVER_ERROR",
594 // SECURITY: see `status_code` — fallback to the safe generic
595 // category until the new variant is explicitly classified.
596 _ => "INTERNAL_SERVER_ERROR",
597 }
598 }
599
600 /// Create an unknown field error with helpful suggestions.
601 #[must_use]
602 pub fn unknown_field_with_suggestion(
603 field: impl Into<String>,
604 type_name: impl Into<String>,
605 available_fields: &[&str],
606 ) -> Self {
607 let field = field.into();
608 let type_name = type_name.into();
609
610 let suggestion = available_fields
611 .iter()
612 .map(|f| (*f, Self::levenshtein_distance(&field, f)))
613 .filter(|(_, distance)| *distance <= 2)
614 .min_by_key(|(_, distance)| *distance)
615 .map(|(f, _)| f);
616
617 if let Some(suggested_field) = suggestion {
618 Self::UnknownField {
619 field: format!("{field} (did you mean '{suggested_field}'?)"),
620 type_name,
621 }
622 } else {
623 Self::UnknownField { field, type_name }
624 }
625 }
626
627 fn levenshtein_distance(s1: &str, s2: &str) -> usize {
628 let chars2: Vec<char> = s2.chars().collect();
629 let len2 = chars2.len();
630
631 // Two-row rolling buffer eliminates the nested `Vec<Vec<_>>` indexing.
632 // `prev[j]` = distance(s1[..i], s2[..j])
633 // `curr[j]` = distance(s1[..i+1], s2[..j])
634 // Both rows have `len2 + 1` entries; iteration is bounded by their
635 // length, so every access uses `.get()?` with an `unreachable_or_zero`
636 // saturating fallback (provably unreachable in this control flow).
637 let mut prev: Vec<usize> = (0..=len2).collect();
638 let mut curr: Vec<usize> = vec![0; len2 + 1];
639
640 for (i, c1) in s1.chars().enumerate() {
641 // Initialise the leftmost column for this row.
642 // Reason: `curr` is sized `len2 + 1 >= 1`, so index 0 is always valid.
643 if let Some(slot) = curr.get_mut(0) {
644 *slot = i + 1;
645 }
646
647 for (j, &c2) in chars2.iter().enumerate() {
648 let cost = usize::from(c1 != c2);
649 // All four lookups read positions in `[0, len2]`, which are
650 // valid by construction (`prev`/`curr` both have len `len2+1`,
651 // and `j` ranges over `0..len2`). The `.get()` + `unwrap_or(0)`
652 // pattern keeps the function panic-free without changing the
653 // computed result.
654 let deletion = prev.get(j + 1).copied().unwrap_or(0).saturating_add(1);
655 let insertion = curr.get(j).copied().unwrap_or(0).saturating_add(1);
656 let substitution = prev.get(j).copied().unwrap_or(0).saturating_add(cost);
657 let value = deletion.min(insertion).min(substitution);
658 if let Some(slot) = curr.get_mut(j + 1) {
659 *slot = value;
660 }
661 }
662
663 std::mem::swap(&mut prev, &mut curr);
664 }
665
666 // Final answer sits at `prev[len2]` after the last swap.
667 // Reason: `prev` is always sized `len2 + 1`, so this index is valid.
668 prev.get(len2).copied().unwrap_or(0)
669 }
670
671 /// Create a database error from PostgreSQL error code.
672 #[must_use]
673 pub fn from_postgres_code(code: &str, message: impl Into<String>) -> Self {
674 let message = message.into();
675 match code {
676 "42P01" => Self::Database {
677 message: "The table or view you're querying doesn't exist. \
678 Check that the schema is compiled and the database is initialized."
679 .to_string(),
680 sql_state: Some(code.to_string()),
681 },
682 "42703" => Self::Database {
683 message: "A column referenced in the query doesn't exist in the table. \
684 This may indicate the database schema is out of sync with the compiled schema."
685 .to_string(),
686 sql_state: Some(code.to_string()),
687 },
688 "23505" => Self::Conflict {
689 message: "A unique constraint was violated. This value already exists in the database.".to_string(),
690 },
691 "23503" => Self::Conflict {
692 message: "A foreign key constraint was violated. The referenced record doesn't exist."
693 .to_string(),
694 },
695 "23502" => Self::Conflict {
696 message: "A NOT NULL constraint was violated. The field cannot be empty.".to_string(),
697 },
698 "22P02" => Self::Validation {
699 message: "Invalid input value. The provided value doesn't match the expected data type.".to_string(),
700 path: None,
701 },
702 _ => Self::Database {
703 message,
704 sql_state: Some(code.to_string()),
705 },
706 }
707 }
708
709 /// Create a rate limit error with retry information.
710 #[must_use]
711 pub fn rate_limited_with_retry(retry_after_secs: u64) -> Self {
712 Self::RateLimited {
713 message: format!(
714 "Rate limit exceeded. Please try again in {retry_after_secs} seconds. \
715 For permanent increases, contact support."
716 ),
717 retry_after_secs,
718 }
719 }
720
721 /// Create an authentication error with context.
722 #[must_use]
723 pub fn auth_error(reason: impl Into<String>) -> Self {
724 Self::Authentication {
725 message: reason.into(),
726 }
727 }
728}
729
730impl From<serde_json::Error> for FraiseQLError {
731 fn from(e: serde_json::Error) -> Self {
732 Self::Parse {
733 message: e.to_string(),
734 location: format!("line {}, column {}", e.line(), e.column()),
735 }
736 }
737}
738
739impl From<std::io::Error> for FraiseQLError {
740 fn from(e: std::io::Error) -> Self {
741 Self::Internal {
742 message: format!("I/O error: {e}"),
743 source: Some(Box::new(e)),
744 }
745 }
746}
747
748impl From<std::env::VarError> for FraiseQLError {
749 fn from(e: std::env::VarError) -> Self {
750 Self::Configuration {
751 message: format!("Environment variable error: {e}"),
752 }
753 }
754}
755
756/// Extension trait for adding context to errors.
757pub trait ErrorContext<T> {
758 /// Add context to an error.
759 ///
760 /// # Errors
761 ///
762 /// Returns `Err` if the original value was `Err`, wrapping it in an `Internal` error with the
763 /// given message.
764 fn context(self, message: impl Into<String>) -> Result<T>;
765
766 /// Add context lazily (only computed on error).
767 ///
768 /// # Errors
769 ///
770 /// Returns `Err` if the original value was `Err`, wrapping it in an `Internal` error with the
771 /// context message.
772 fn with_context<F, M>(self, f: F) -> Result<T>
773 where
774 F: FnOnce() -> M,
775 M: Into<String>;
776}
777
778impl<T, E: Into<FraiseQLError>> ErrorContext<T> for std::result::Result<T, E> {
779 fn context(self, message: impl Into<String>) -> Result<T> {
780 self.map_err(|e| {
781 let inner = e.into();
782 FraiseQLError::Internal {
783 message: format!("{}: {inner}", message.into()),
784 source: None,
785 }
786 })
787 }
788
789 fn with_context<F, M>(self, f: F) -> Result<T>
790 where
791 F: FnOnce() -> M,
792 M: Into<String>,
793 {
794 self.map_err(|e| {
795 let inner = e.into();
796 FraiseQLError::Internal {
797 message: format!("{}: {inner}", f().into()),
798 source: None,
799 }
800 })
801 }
802}
803
804/// A validation error for a specific field in an input object.
805#[derive(Debug, Clone, Serialize, Deserialize)]
806pub struct ValidationFieldError {
807 /// Path to the field that failed validation.
808 pub field: String,
809 /// Type of validation rule that failed.
810 pub rule_type: String,
811 /// Human-readable error message.
812 pub message: String,
813}
814
815impl ValidationFieldError {
816 /// Create a new validation field error.
817 #[must_use]
818 pub fn new(
819 field: impl Into<String>,
820 rule_type: impl Into<String>,
821 message: impl Into<String>,
822 ) -> Self {
823 Self {
824 field: field.into(),
825 rule_type: rule_type.into(),
826 message: message.into(),
827 }
828 }
829}
830
831impl std::fmt::Display for ValidationFieldError {
832 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
833 write!(f, "{} ({}): {}", self.field, self.rule_type, self.message)
834 }
835}
836
837#[cfg(test)]
838mod tests;