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 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}