Skip to main content

fraiseql_error/
lib.rs

1//! Unified error types for FraiseQL runtime crates.
2//!
3//! All runtime crates depend on this crate for error handling.
4//!
5//! # Two-Layer Error Design
6//!
7//! FraiseQL uses two distinct error layers with separate responsibilities:
8//!
9//! ## Layer 1 — Compile-time errors: [`FraiseQLError`] (in `core_error`)
10//!
11//! Returned by schema compilation, query planning, and the CLI. These are
12//! **developer-facing errors** that occur before any user request is processed.
13//! Library consumers building atop the `Executor` API will encounter this type.
14//!
15//! ```rust,ignore
16//! use fraiseql_error::{FraiseQLError, Result};
17//!
18//! fn compile_schema(json: &str) -> Result<CompiledSchema> {
19//!     // Returns FraiseQLError::Parse if the JSON is malformed,
20//!     // FraiseQLError::Validation if the schema is semantically invalid.
21//!     todo!()
22//! }
23//! ```
24//!
25//! [`FraiseQLError`] variants: `Parse`, `Validation`, `Database`, `Compilation`,
26//! `IO`, `Config`, `Unsupported`, `Auth`, `Timeout`, `NotFound`.
27//!
28//! ## Layer 2 — Runtime errors: [`RuntimeError`]
29//!
30//! Returned by the HTTP server and live request handlers. These map directly
31//! to HTTP status codes and are safe to return to API clients (internal details
32//! are stripped before the response is sent).
33//!
34//! Application code using the `Server` API will encounter this type.
35//! Conversion from `FraiseQLError` to `RuntimeError` happens automatically inside
36//! the request-handler middleware via `From<FraiseQLError> for RuntimeError`.
37//!
38//! ## Conversion rules
39//!
40//! `FraiseQLError` → `RuntimeError` mapping:
41//! - `Validation`, `Parse`  → `RuntimeError::Internal` (HTTP 400 via handler logic)
42//! - `Database`             → `RuntimeError::Database` (HTTP 500)
43//! - `Auth`                 → `RuntimeError::Auth`     (HTTP 401/403)
44//! - `NotFound`             → `RuntimeError::NotFound` (HTTP 404)
45//! - Everything else        → `RuntimeError::Internal` (HTTP 500)
46//!
47//! # `RuntimeError` → HTTP mapping
48//!
49//! | `RuntimeError` variant            | HTTP status                  |
50//! |-----------------------------------|------------------------------|
51//! | `Auth(InsufficientPermissions)`   | 403 Forbidden                |
52//! | `Auth(*)`                         | 401 Unauthorized             |
53//! | `Webhook(InvalidSignature)`       | 401 Unauthorized             |
54//! | `RateLimited`                     | 429 Too Many Requests        |
55//! | `ServiceUnavailable`              | 503 Service Unavailable      |
56//! | `NotFound`                        | 404 Not Found                |
57//! | `Database`                        | 500 Internal Server Error    |
58//! | `Config` / `Internal`             | 500 Internal Server Error    |
59//!
60//! # Security note
61//!
62//! All variants that might leak internal details (database messages, config values,
63//! provider endpoints) return **generic** descriptions in the HTTP response body.
64//! Raw error details are available only in structured server logs.
65//!
66//! See also: [`docs/architecture/error-hierarchy.md`](https://docs.fraiseql.dev/architecture/error-hierarchy)
67
68#![warn(missing_docs)]
69
70mod auth;
71mod config;
72pub mod core_error;
73mod file;
74pub mod graphql_error;
75#[cfg(feature = "axum-compat")]
76mod http;
77mod integration;
78mod notification;
79mod observer;
80mod webhook;
81
82pub use auth::AuthError;
83pub use config::ConfigError;
84pub use core_error::{ErrorContext, FraiseQLError, Result, ValidationFieldError};
85pub use file::FileError;
86pub use graphql_error::{GraphQLError, GraphQLErrorLocation};
87// Re-export for convenience — only available with the `axum-compat` feature
88#[cfg(feature = "axum-compat")]
89pub use http::{ErrorResponse, IntoHttpResponse};
90pub use integration::IntegrationError;
91pub use notification::NotificationError;
92pub use observer::ObserverError;
93pub use webhook::WebhookError;
94
95/// Unified error type wrapping all domain errors.
96///
97/// `RuntimeError` aggregates every domain-level error that can surface during
98/// request handling. It implements [`axum::response::IntoResponse`] so that
99/// handlers can return `Result<_, RuntimeError>` directly; the conversion
100/// produces an [`ErrorResponse`] JSON body with the appropriate HTTP status
101/// code. Sensitive internal details (database messages, config values) are
102/// stripped from the HTTP response and are only present in server-side logs.
103#[derive(Debug, thiserror::Error)]
104#[non_exhaustive]
105pub enum RuntimeError {
106    /// A configuration error, such as an invalid or missing config file.
107    #[error(transparent)]
108    Config(#[from] ConfigError),
109
110    /// An authentication or authorisation error (invalid token, expired session, etc.).
111    #[error(transparent)]
112    Auth(#[from] AuthError),
113
114    /// A webhook validation error (bad signature, duplicate event, expired timestamp, etc.).
115    #[error(transparent)]
116    Webhook(#[from] WebhookError),
117
118    /// A file-handling error (size limit exceeded, unsupported type, virus detected, etc.).
119    #[error(transparent)]
120    File(#[from] FileError),
121
122    /// A notification delivery error (provider unavailable, circuit open, rate-limited, etc.).
123    #[error(transparent)]
124    Notification(#[from] NotificationError),
125
126    /// An observer/event processing error (invalid condition, action failure, etc.).
127    #[error(transparent)]
128    Observer(#[from] ObserverError),
129
130    /// An external integration error (search, cache, queue, or connection failure).
131    #[error(transparent)]
132    Integration(#[from] IntegrationError),
133
134    /// A database-level error propagated from sqlx.
135    ///
136    /// The raw sqlx message is available for logging but is never exposed in
137    /// HTTP responses (returns a generic "database error" description instead).
138    #[error("Database error: {0}")]
139    Database(#[from] sqlx::Error),
140
141    /// The caller has exceeded the configured request rate limit.
142    ///
143    /// `retry_after` is the number of seconds to wait before retrying, if known.
144    #[error("Rate limit exceeded")]
145    RateLimited {
146        /// Number of seconds to wait before retrying, if known.
147        retry_after: Option<u64>,
148    },
149
150    /// A downstream service or dependency is temporarily unavailable.
151    ///
152    /// `retry_after` is the number of seconds to wait before retrying, if known.
153    #[error("Service unavailable: {reason}")]
154    ServiceUnavailable {
155        /// Human-readable reason for the outage (server-side logs only).
156        reason:      String,
157        /// Number of seconds to wait before retrying, if known.
158        retry_after: Option<u64>,
159    },
160
161    /// The requested resource does not exist.
162    #[error("Resource not found: {resource}")]
163    NotFound {
164        /// Description of the resource that was not found.
165        resource: String,
166    },
167
168    /// An unexpected internal server error.
169    ///
170    /// Use this variant when no more specific variant applies. The `message`
171    /// is recorded in server logs but is never forwarded to clients.
172    #[error("Internal error: {message}")]
173    Internal {
174        /// Internal error message (not forwarded to clients).
175        message: String,
176        /// Optional chained error for structured logging.
177        #[source]
178        source:  Option<Box<dyn std::error::Error + Send + Sync>>,
179    },
180}
181
182impl RuntimeError {
183    /// Get the error code for this error
184    pub const fn error_code(&self) -> &'static str {
185        match self {
186            Self::Config(e) => e.error_code(),
187            Self::Auth(e) => e.error_code(),
188            Self::Webhook(e) => e.error_code(),
189            Self::File(e) => e.error_code(),
190            Self::Notification(e) => e.error_code(),
191            Self::Observer(e) => e.error_code(),
192            Self::Integration(e) => e.error_code(),
193            Self::Database(_) => "database_error",
194            Self::RateLimited { .. } => "rate_limited",
195            Self::ServiceUnavailable { .. } => "service_unavailable",
196            Self::NotFound { .. } => "not_found",
197            Self::Internal { .. } => "internal_error",
198        }
199    }
200
201    /// Get documentation URL for this error
202    pub fn docs_url(&self) -> String {
203        format!("https://docs.fraiseql.dev/errors#{}", self.error_code())
204    }
205}