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