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