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//! # Error bridging contract
6//!
7//! [`RuntimeError`] is the domain-level error enum that aggregates all business-logic errors
8//! (auth, webhooks, files, notifications, etc.). It implements [`axum::response::IntoResponse`]
9//! via the `http` module's `IntoResponse` impl, which converts it to an [`ErrorResponse`] JSON body
10//! with the appropriate HTTP status code:
11//!
12//! ```text
13//! RuntimeError (domain)
14//! ↓ IntoResponse (via fraiseql-error::http)
15//! ErrorResponse { error, error_description, error_code, error_uri, details, retry_after }
16//! ↓ Json(response) + StatusCode
17//! HTTP response body (application/json)
18//! ```
19//!
20//! ## Mapping rules
21//!
22//! | `RuntimeError` variant | HTTP status |
23//! |-----------------------------------|------------------------------|
24//! | `Auth(InsufficientPermissions)` | 403 Forbidden |
25//! | `Auth(*)` | 401 Unauthorized |
26//! | `Webhook(InvalidSignature)` | 401 Unauthorized |
27//! | `RateLimited` | 429 Too Many Requests |
28//! | `ServiceUnavailable` | 503 Service Unavailable |
29//! | `NotFound` | 404 Not Found |
30//! | `Database` | 500 Internal Server Error |
31//! | `Config` / `Internal` | 500 Internal Server Error |
32//!
33//! ## Security note
34//!
35//! All variants that might leak internal details (database messages, config values,
36//! provider endpoints) return **generic** descriptions in the HTTP response body.
37//! Raw error details are available only in structured server logs.
38
39#![warn(missing_docs)]
40
41mod auth;
42mod config;
43pub mod core_error;
44mod file;
45pub mod graphql_error;
46#[cfg(feature = "axum-compat")]
47mod http;
48mod integration;
49mod notification;
50mod observer;
51mod webhook;
52
53pub use auth::AuthError;
54pub use config::ConfigError;
55pub use core_error::{ErrorContext, FraiseQLError, Result, ValidationFieldError};
56pub use file::FileError;
57pub use graphql_error::{GraphQLError, GraphQLErrorLocation};
58// Re-export for convenience — only available with the `axum-compat` feature
59#[cfg(feature = "axum-compat")]
60pub use http::{ErrorResponse, IntoHttpResponse};
61pub use integration::IntegrationError;
62pub use notification::NotificationError;
63pub use observer::ObserverError;
64pub use webhook::WebhookError;
65
66/// Unified error type wrapping all domain errors.
67///
68/// `RuntimeError` aggregates every domain-level error that can surface during
69/// request handling. It implements [`axum::response::IntoResponse`] so that
70/// handlers can return `Result<_, RuntimeError>` directly; the conversion
71/// produces an [`ErrorResponse`] JSON body with the appropriate HTTP status
72/// code. Sensitive internal details (database messages, config values) are
73/// stripped from the HTTP response and are only present in server-side logs.
74#[derive(Debug, thiserror::Error)]
75#[non_exhaustive]
76pub enum RuntimeError {
77 /// A configuration error, such as an invalid or missing config file.
78 #[error(transparent)]
79 Config(#[from] ConfigError),
80
81 /// An authentication or authorisation error (invalid token, expired session, etc.).
82 #[error(transparent)]
83 Auth(#[from] AuthError),
84
85 /// A webhook validation error (bad signature, duplicate event, expired timestamp, etc.).
86 #[error(transparent)]
87 Webhook(#[from] WebhookError),
88
89 /// A file-handling error (size limit exceeded, unsupported type, virus detected, etc.).
90 #[error(transparent)]
91 File(#[from] FileError),
92
93 /// A notification delivery error (provider unavailable, circuit open, rate-limited, etc.).
94 #[error(transparent)]
95 Notification(#[from] NotificationError),
96
97 /// An observer/event processing error (invalid condition, action failure, etc.).
98 #[error(transparent)]
99 Observer(#[from] ObserverError),
100
101 /// An external integration error (search, cache, queue, or connection failure).
102 #[error(transparent)]
103 Integration(#[from] IntegrationError),
104
105 /// A database-level error propagated from sqlx.
106 ///
107 /// The raw sqlx message is available for logging but is never exposed in
108 /// HTTP responses (returns a generic "database error" description instead).
109 #[error("Database error: {0}")]
110 Database(#[from] sqlx::Error),
111
112 /// The caller has exceeded the configured request rate limit.
113 ///
114 /// `retry_after` is the number of seconds to wait before retrying, if known.
115 #[error("Rate limit exceeded")]
116 RateLimited {
117 /// Number of seconds to wait before retrying, if known.
118 retry_after: Option<u64>,
119 },
120
121 /// A downstream service or dependency is temporarily unavailable.
122 ///
123 /// `retry_after` is the number of seconds to wait before retrying, if known.
124 #[error("Service unavailable: {reason}")]
125 ServiceUnavailable {
126 /// Human-readable reason for the outage (server-side logs only).
127 reason: String,
128 /// Number of seconds to wait before retrying, if known.
129 retry_after: Option<u64>,
130 },
131
132 /// The requested resource does not exist.
133 #[error("Resource not found: {resource}")]
134 NotFound {
135 /// Description of the resource that was not found.
136 resource: String,
137 },
138
139 /// An unexpected internal server error.
140 ///
141 /// Use this variant when no more specific variant applies. The `message`
142 /// is recorded in server logs but is never forwarded to clients.
143 #[error("Internal error: {message}")]
144 Internal {
145 /// Internal error message (not forwarded to clients).
146 message: String,
147 /// Optional chained error for structured logging.
148 #[source]
149 source: Option<Box<dyn std::error::Error + Send + Sync>>,
150 },
151}
152
153impl RuntimeError {
154 /// Get the error code for this error
155 pub const fn error_code(&self) -> &'static str {
156 match self {
157 Self::Config(e) => e.error_code(),
158 Self::Auth(e) => e.error_code(),
159 Self::Webhook(e) => e.error_code(),
160 Self::File(e) => e.error_code(),
161 Self::Notification(e) => e.error_code(),
162 Self::Observer(e) => e.error_code(),
163 Self::Integration(e) => e.error_code(),
164 Self::Database(_) => "database_error",
165 Self::RateLimited { .. } => "rate_limited",
166 Self::ServiceUnavailable { .. } => "service_unavailable",
167 Self::NotFound { .. } => "not_found",
168 Self::Internal { .. } => "internal_error",
169 }
170 }
171
172 /// Get documentation URL for this error
173 pub fn docs_url(&self) -> String {
174 format!("https://docs.fraiseql.dev/errors#{}", self.error_code())
175 }
176}