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}