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//! # 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}