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