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