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