Skip to main content

fraiseql_server/
error.rs

1//! GraphQL error response handling.
2//!
3//! Implements GraphQL spec-compliant error responses with:
4//! - Error codes for client-side handling
5//! - Location tracking in queries
6//! - Extensions for custom error data
7
8use axum::{
9    Json,
10    http::StatusCode,
11    response::{IntoResponse, Response},
12};
13use serde::Serialize;
14
15/// GraphQL error code enumeration.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
17#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
18#[non_exhaustive]
19pub enum ErrorCode {
20    /// Validation error.
21    ValidationError,
22    /// Parse error.
23    ParseError,
24    /// Request error.
25    RequestError,
26    /// Authentication required.
27    Unauthenticated,
28    /// Access denied.
29    Forbidden,
30    /// Internal server error.
31    InternalServerError,
32    /// Database error.
33    DatabaseError,
34    /// Client-input data exception (SQLSTATE class 22, e.g. a malformed value that
35    /// failed a cast) — the request was invalid, not the server (HTTP 400).
36    BadUserInput,
37    /// Integrity-constraint violation (SQLSTATE class 23, e.g. not-null / unique /
38    /// foreign-key / check) caused by client input (HTTP 400).
39    ConstraintViolation,
40    /// Timeout error.
41    Timeout,
42    /// Rate limit exceeded.
43    RateLimitExceeded,
44    /// Not found.
45    NotFound,
46    /// Conflict.
47    Conflict,
48    /// Circuit breaker open — federation entity temporarily unavailable.
49    CircuitBreakerOpen,
50    /// Service temporarily unavailable (e.g. a suspended tenant). Maps to HTTP
51    /// 503 and carries a `Retry-After` header when a retry hint is known.
52    ServiceUnavailable,
53    /// Persisted query not found — client must re-send the full query body.
54    PersistedQueryNotFound,
55    /// Persisted query hash mismatch — SHA-256 of body does not match provided hash.
56    PersistedQueryMismatch,
57    /// Raw query forbidden — trusted documents strict mode requires a documentId.
58    ForbiddenQuery,
59    /// Document not found — the provided documentId is not in the trusted manifest.
60    DocumentNotFound,
61    /// Operation not allowed for the HTTP method (e.g. a mutation sent over GET).
62    MethodNotAllowed,
63}
64
65impl ErrorCode {
66    /// Get HTTP status code for this error.
67    ///
68    /// Follows the [GraphQL over HTTP spec](https://graphql.github.io/graphql-over-http/):
69    /// a well-formed GraphQL request that fails validation or parsing returns **200 OK**
70    /// with `{"errors": [...]}` in the body — never a 4xx — so that standard HTTP clients
71    /// can read the error message rather than raising a transport-level exception.
72    ///
73    /// Only [`RequestError`](Self::RequestError) uses 400, because it indicates a truly
74    /// malformed HTTP request (missing `query` field, unreadable JSON body) that was never
75    /// a valid GraphQL request to begin with.
76    #[must_use]
77    pub const fn status_code(self) -> StatusCode {
78        match self {
79            // Spec §7.1.2: well-formed requests that fail GraphQL validation, parsing,
80            // or APQ "not found" (signal for client to re-send with query body)
81            // MUST return 2xx.
82            Self::ValidationError | Self::ParseError | Self::PersistedQueryNotFound => {
83                StatusCode::OK
84            },
85            // Truly malformed HTTP request (missing `query` field, unparseable JSON body),
86            // APQ hash mismatch, forbidden queries, or missing trusted documents.
87            Self::RequestError
88            | Self::PersistedQueryMismatch
89            | Self::ForbiddenQuery
90            | Self::DocumentNotFound
91            | Self::BadUserInput
92            | Self::ConstraintViolation => StatusCode::BAD_REQUEST,
93            Self::Unauthenticated => StatusCode::UNAUTHORIZED,
94            Self::Forbidden => StatusCode::FORBIDDEN,
95            Self::NotFound => StatusCode::NOT_FOUND,
96            Self::Conflict => StatusCode::CONFLICT,
97            Self::RateLimitExceeded => StatusCode::TOO_MANY_REQUESTS,
98            Self::Timeout => StatusCode::REQUEST_TIMEOUT,
99            Self::InternalServerError | Self::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
100            Self::CircuitBreakerOpen | Self::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE,
101            // GraphQL-over-HTTP: a mutation sent over GET is rejected at the transport
102            // layer (405), not returned as a 2xx GraphQL error.
103            Self::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
104        }
105    }
106}
107
108/// A Postgres SQLSTATE class that indicates a **client-input** fault (HTTP 4xx)
109/// rather than a server fault (HTTP 5xx).
110///
111/// Shared by the GraphQL and REST error mappers so both transports classify a
112/// `FraiseQLError::Database` identically (#413).
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub(crate) enum ClientInputSqlState {
115    /// SQLSTATE class `22` — data exception (e.g. `22P02` invalid text representation:
116    /// a malformed `uuid`/`inet`/`numeric` value that failed a cast).
117    DataException,
118    /// SQLSTATE class `23` — integrity constraint violation (`23502` not-null,
119    /// `23503` foreign-key, `23505` unique, `23514` check).
120    IntegrityConstraint,
121}
122
123/// Classify a Postgres SQLSTATE as a client-input fault, if it is one.
124///
125/// Returns `None` for every other class (connection loss, internal `PLpgSQL` faults,
126/// …) and for an absent SQLSTATE — those stay HTTP 500 / `DATABASE_ERROR`.
127pub(crate) fn classify_client_input_sqlstate(
128    sql_state: Option<&str>,
129) -> Option<ClientInputSqlState> {
130    match sql_state {
131        Some(s) if s.starts_with("22") => Some(ClientInputSqlState::DataException),
132        Some(s) if s.starts_with("23") => Some(ClientInputSqlState::IntegrityConstraint),
133        _ => None,
134    }
135}
136
137/// Error location in GraphQL query.
138#[derive(Debug, Clone, Serialize)]
139pub struct ErrorLocation {
140    /// Line number (1-indexed).
141    pub line:   usize,
142    /// Column number (1-indexed).
143    pub column: usize,
144}
145
146/// GraphQL error following spec.
147#[derive(Debug, Clone, Serialize)]
148pub struct GraphQLError {
149    /// Error message.
150    pub message: String,
151
152    /// Error code for client handling.
153    pub code: ErrorCode,
154
155    /// Location in query where error occurred.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub locations: Option<Vec<ErrorLocation>>,
158
159    /// Path to field that caused error.
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub path: Option<Vec<String>>,
162
163    /// Additional error information.
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub extensions: Option<ErrorExtensions>,
166}
167
168/// Additional error context and debugging information.
169#[derive(Debug, Clone, Serialize)]
170pub struct ErrorExtensions {
171    /// Error category.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub category: Option<String>,
174
175    /// HTTP status code.
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub status: Option<u16>,
178
179    /// Request ID for tracking.
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub request_id: Option<String>,
182
183    /// Seconds until the client may retry (set for `CircuitBreakerOpen` errors).
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub retry_after_secs: Option<u64>,
186
187    /// Internal error detail (SQL fragment, stack trace, etc.).
188    ///
189    /// Stripped from responses when error sanitization is enabled.
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub detail: Option<String>,
192}
193
194/// GraphQL response with errors.
195#[derive(Debug, Serialize)]
196pub struct ErrorResponse {
197    /// Errors that occurred.
198    pub errors: Vec<GraphQLError>,
199}
200
201impl GraphQLError {
202    /// Create a new GraphQL error.
203    pub fn new(message: impl Into<String>, code: ErrorCode) -> Self {
204        Self {
205            message: message.into(),
206            code,
207            locations: None,
208            path: None,
209            extensions: None,
210        }
211    }
212
213    /// Add location to error.
214    #[must_use]
215    pub fn with_location(mut self, line: usize, column: usize) -> Self {
216        self.locations = Some(vec![ErrorLocation { line, column }]);
217        self
218    }
219
220    /// Add path to error.
221    #[must_use]
222    pub fn with_path(mut self, path: Vec<String>) -> Self {
223        self.path = Some(path);
224        self
225    }
226
227    /// Add extensions to error.
228    #[must_use]
229    pub fn with_extensions(mut self, extensions: ErrorExtensions) -> Self {
230        self.extensions = Some(extensions);
231        self
232    }
233
234    /// Add request ID for distributed tracing.
235    #[must_use]
236    pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
237        let request_id = request_id.into();
238        let extensions = self.extensions.take().unwrap_or(ErrorExtensions {
239            category:         None,
240            status:           None,
241            request_id:       None,
242            retry_after_secs: None,
243            detail:           None,
244        });
245
246        self.extensions = Some(ErrorExtensions {
247            request_id: Some(request_id),
248            ..extensions
249        });
250        self
251    }
252
253    /// Validation error.
254    pub fn validation(message: impl Into<String>) -> Self {
255        Self::new(message, ErrorCode::ValidationError)
256    }
257
258    /// Parse error with hint for common syntax issues.
259    pub fn parse(message: impl Into<String>) -> Self {
260        Self::new(message, ErrorCode::ParseError)
261    }
262
263    /// Request error with validation details.
264    pub fn request(message: impl Into<String>) -> Self {
265        Self::new(message, ErrorCode::RequestError)
266    }
267
268    /// Method-not-allowed error (e.g. a mutation sent over HTTP GET). Maps to 405.
269    pub fn method_not_allowed(message: impl Into<String>) -> Self {
270        Self::new(message, ErrorCode::MethodNotAllowed)
271    }
272
273    /// Database error - includes connection, timeout, and query errors.
274    pub fn database(message: impl Into<String>) -> Self {
275        Self::new(message, ErrorCode::DatabaseError)
276    }
277
278    /// Internal server error - unexpected conditions.
279    pub fn internal(message: impl Into<String>) -> Self {
280        Self::new(message, ErrorCode::InternalServerError)
281    }
282
283    /// Execution error during GraphQL resolver execution.
284    ///
285    /// # Deprecation
286    ///
287    /// Prefer [`GraphQLError::from_fraiseql_error`] on the hot path; it preserves
288    /// the specific error variant so clients and the sanitizer receive the correct code.
289    /// This method remains for ad-hoc internal errors that do not originate from a
290    /// `FraiseQLError`.
291    #[doc(hidden)]
292    #[must_use]
293    pub fn execution(message: &str) -> Self {
294        Self::new(message, ErrorCode::InternalServerError)
295    }
296
297    /// Unauthenticated error - authentication token is missing or invalid.
298    #[must_use]
299    pub fn unauthenticated() -> Self {
300        Self::new("Authentication required", ErrorCode::Unauthenticated)
301    }
302
303    /// Forbidden error - user lacks permission to access resource.
304    #[must_use]
305    pub fn forbidden() -> Self {
306        Self::new("Access denied", ErrorCode::Forbidden)
307    }
308
309    /// Not found error - requested resource does not exist.
310    pub fn not_found(message: impl Into<String>) -> Self {
311        Self::new(message, ErrorCode::NotFound)
312    }
313
314    /// Timeout error - operation took too long and was cancelled.
315    pub fn timeout(operation: impl Into<String>) -> Self {
316        Self::new(format!("{} exceeded timeout", operation.into()), ErrorCode::Timeout)
317    }
318
319    /// Rate limit error - too many requests from client.
320    pub fn rate_limited(message: impl Into<String>) -> Self {
321        Self::new(message, ErrorCode::RateLimitExceeded)
322    }
323
324    /// Construct a typed [`GraphQLError`] from a [`fraiseql_core::error::FraiseQLError`] executor
325    /// error.
326    ///
327    /// Maps specific core error variants to their closest HTTP-semantic equivalent,
328    /// preserving type information for correct client handling and sanitizer routing.
329    #[must_use]
330    pub fn from_fraiseql_error(err: &fraiseql_core::error::FraiseQLError) -> Self {
331        use fraiseql_core::error::FraiseQLError as E;
332        match err {
333            // Classify client-input DB faults (SQLSTATE 22xxx/23xxx) as 400 rather than
334            // 500 (#413); genuine server faults (other classes, no SQLSTATE, connection
335            // pool) stay 500 / DATABASE_ERROR.
336            E::Database { sql_state, .. } => {
337                match classify_client_input_sqlstate(sql_state.as_deref()) {
338                    Some(ClientInputSqlState::DataException) => {
339                        Self::new(err.to_string(), ErrorCode::BadUserInput)
340                    },
341                    Some(ClientInputSqlState::IntegrityConstraint) => {
342                        Self::new(err.to_string(), ErrorCode::ConstraintViolation)
343                    },
344                    None => Self::database(err.to_string()),
345                }
346            },
347            E::ConnectionPool { .. } => Self::database(err.to_string()),
348            E::Parse { .. } => Self::parse(err.to_string()),
349            E::Validation { .. } | E::UnknownField { .. } | E::UnknownType { .. } => {
350                Self::validation(err.to_string())
351            },
352            E::NotFound { .. } => Self::not_found(err.to_string()),
353            E::Conflict { .. } => Self::new(err.to_string(), ErrorCode::Conflict),
354            E::Authorization { .. } => Self::forbidden(),
355            E::Authentication { .. } => Self::unauthenticated(),
356            E::Timeout { .. } => Self::new(err.to_string(), ErrorCode::Timeout),
357            E::RateLimited { message, .. } => Self::rate_limited(message.clone()),
358            // Cancelled, Configuration, Internal, and any future variants
359            _ => Self::internal(err.to_string()),
360        }
361    }
362
363    /// Persisted query not found — client must re-send the full query body.
364    #[must_use]
365    pub fn persisted_query_not_found() -> Self {
366        Self::new("PersistedQueryNotFound", ErrorCode::PersistedQueryNotFound)
367    }
368
369    /// Persisted query hash mismatch — SHA-256 of body does not match the provided hash.
370    #[must_use]
371    pub fn persisted_query_mismatch() -> Self {
372        Self::new("provided sha does not match query", ErrorCode::PersistedQueryMismatch)
373    }
374
375    /// Raw query forbidden — trusted documents strict mode requires a documentId.
376    #[must_use]
377    pub fn forbidden_query() -> Self {
378        Self::new(
379            "Raw queries are not permitted. Send a documentId instead.",
380            ErrorCode::ForbiddenQuery,
381        )
382    }
383
384    /// Document not found — the provided documentId is not in the trusted manifest.
385    pub fn document_not_found(doc_id: impl Into<String>) -> Self {
386        Self::new(format!("Unknown document: {}", doc_id.into()), ErrorCode::DocumentNotFound)
387    }
388
389    /// Circuit breaker open — federation entity temporarily unavailable.
390    ///
391    /// The response will carry a `Retry-After` header set to `retry_after_secs`.
392    #[must_use]
393    pub fn circuit_breaker_open(entity: &str, retry_after_secs: u64) -> Self {
394        Self::new(
395            format!(
396                "Federation entity '{entity}' is temporarily unavailable. \
397                 Please retry after {retry_after_secs} seconds."
398            ),
399            ErrorCode::CircuitBreakerOpen,
400        )
401        .with_extensions(ErrorExtensions {
402            category:         Some("CIRCUIT_BREAKER".to_string()),
403            status:           Some(503),
404            request_id:       None,
405            retry_after_secs: Some(retry_after_secs),
406            detail:           None,
407        })
408    }
409
410    /// Service temporarily unavailable (HTTP 503), e.g. a suspended tenant.
411    ///
412    /// When `retry_after_secs` is set the response carries a matching
413    /// `Retry-After` header (see [`ErrorResponse`]'s `IntoResponse`).
414    #[must_use]
415    pub fn service_unavailable(message: impl Into<String>, retry_after_secs: Option<u64>) -> Self {
416        Self::new(message, ErrorCode::ServiceUnavailable).with_extensions(ErrorExtensions {
417            category: Some("SERVICE_UNAVAILABLE".to_string()),
418            status: Some(503),
419            request_id: None,
420            retry_after_secs,
421            detail: None,
422        })
423    }
424}
425
426impl ErrorResponse {
427    /// Create new error response.
428    #[must_use]
429    pub const fn new(errors: Vec<GraphQLError>) -> Self {
430        Self { errors }
431    }
432
433    /// Create from single error.
434    #[must_use]
435    pub fn from_error(error: GraphQLError) -> Self {
436        Self {
437            errors: vec![error],
438        }
439    }
440}
441
442impl IntoResponse for ErrorResponse {
443    fn into_response(self) -> Response {
444        let status = self
445            .errors
446            .first()
447            .map_or(StatusCode::INTERNAL_SERVER_ERROR, |e| e.code.status_code());
448
449        let retry_after = self
450            .errors
451            .first()
452            .and_then(|e| e.extensions.as_ref())
453            .and_then(|ext| ext.retry_after_secs);
454
455        let mut response = (status, Json(self)).into_response();
456
457        if let Some(secs) = retry_after {
458            if let Ok(value) = secs.to_string().parse() {
459                response.headers_mut().insert(axum::http::header::RETRY_AFTER, value);
460            }
461        }
462
463        response
464    }
465}
466
467impl From<GraphQLError> for ErrorResponse {
468    fn from(error: GraphQLError) -> Self {
469        Self::from_error(error)
470    }
471}