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}
393
394#[cfg(test)]
395mod tests {
396    #![allow(clippy::unwrap_used)] // Reason: test code, panics acceptable
397    #![allow(clippy::cast_precision_loss)] // Reason: test metrics reporting
398    #![allow(clippy::cast_sign_loss)] // Reason: test data uses small positive integers
399    #![allow(clippy::cast_possible_truncation)] // Reason: test data values are bounded
400    #![allow(clippy::cast_possible_wrap)] // Reason: test data values are bounded
401    #![allow(clippy::missing_panics_doc)] // Reason: test helpers
402    #![allow(clippy::missing_errors_doc)] // Reason: test helpers
403    #![allow(missing_docs)] // Reason: test code
404    #![allow(clippy::items_after_statements)] // Reason: test helpers defined near use site
405
406    use super::*;
407
408    #[test]
409    fn test_error_serialization() {
410        let error = GraphQLError::validation("Invalid query")
411            .with_location(1, 5)
412            .with_path(vec!["user".to_string(), "id".to_string()]);
413
414        let json = serde_json::to_string(&error).unwrap();
415        assert!(json.contains("Invalid query"));
416        assert!(json.contains("VALIDATION_ERROR"));
417        assert!(json.contains("\"line\":1"));
418    }
419
420    #[test]
421    fn test_error_response_serialization() {
422        let response = ErrorResponse::new(vec![
423            GraphQLError::validation("Field not found"),
424            GraphQLError::database("Connection timeout"),
425        ]);
426
427        let json = serde_json::to_string(&response).unwrap();
428        assert!(json.contains("Field not found"));
429        assert!(json.contains("Connection timeout"));
430    }
431
432    #[test]
433    fn test_error_code_status_codes() {
434        // GraphQL-over-HTTP spec: validation/parse errors → 200 (client must read the body)
435        assert_eq!(ErrorCode::ValidationError.status_code(), StatusCode::OK);
436        assert_eq!(ErrorCode::ParseError.status_code(), StatusCode::OK);
437        // Truly malformed HTTP request → 400
438        assert_eq!(ErrorCode::RequestError.status_code(), StatusCode::BAD_REQUEST);
439        assert_eq!(ErrorCode::Unauthenticated.status_code(), StatusCode::UNAUTHORIZED);
440        assert_eq!(ErrorCode::Forbidden.status_code(), StatusCode::FORBIDDEN);
441        assert_eq!(ErrorCode::DatabaseError.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
442        assert_eq!(ErrorCode::CircuitBreakerOpen.status_code(), StatusCode::SERVICE_UNAVAILABLE);
443    }
444
445    #[test]
446    fn test_circuit_breaker_open_error() {
447        let error = GraphQLError::circuit_breaker_open("Product", 30);
448        assert_eq!(error.code, ErrorCode::CircuitBreakerOpen);
449        assert!(error.message.contains("Product"));
450        assert!(error.message.contains("30"));
451        let ext = error.extensions.unwrap();
452        assert_eq!(ext.retry_after_secs, Some(30));
453        assert_eq!(ext.category, Some("CIRCUIT_BREAKER".to_string()));
454    }
455
456    #[test]
457    fn test_circuit_breaker_response_has_retry_after_header() {
458        use axum::response::IntoResponse;
459
460        let response = ErrorResponse::from_error(GraphQLError::circuit_breaker_open("User", 60))
461            .into_response();
462        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
463        let retry_after = response.headers().get(axum::http::header::RETRY_AFTER);
464        assert_eq!(retry_after.and_then(|v| v.to_str().ok()), Some("60"));
465    }
466
467    #[test]
468    fn test_from_fraiseql_error_database_maps_to_database_code() {
469        use fraiseql_core::error::FraiseQLError;
470        let err = FraiseQLError::Database {
471            message:   "relation \"users\" does not exist".into(),
472            sql_state: None,
473        };
474        let graphql_err = GraphQLError::from_fraiseql_error(&err);
475        assert_eq!(graphql_err.code, ErrorCode::DatabaseError);
476    }
477
478    #[test]
479    fn test_from_fraiseql_error_validation_maps_to_validation_code() {
480        use fraiseql_core::error::FraiseQLError;
481        let err = FraiseQLError::Validation {
482            message: "field 'id' is required".into(),
483            path:    None,
484        };
485        let graphql_err = GraphQLError::from_fraiseql_error(&err);
486        assert_eq!(graphql_err.code, ErrorCode::ValidationError);
487    }
488
489    #[test]
490    fn test_from_fraiseql_error_not_found_maps_to_not_found_code() {
491        use fraiseql_core::error::FraiseQLError;
492        let err = FraiseQLError::NotFound {
493            resource_type: "User".into(),
494            identifier:    "123".into(),
495        };
496        let graphql_err = GraphQLError::from_fraiseql_error(&err);
497        assert_eq!(graphql_err.code, ErrorCode::NotFound);
498    }
499
500    #[test]
501    fn test_from_fraiseql_error_authorization_maps_to_forbidden() {
502        use fraiseql_core::error::FraiseQLError;
503        let err = FraiseQLError::Authorization {
504            message:  "insufficient permissions".into(),
505            action:   Some("write".into()),
506            resource: Some("User".into()),
507        };
508        let graphql_err = GraphQLError::from_fraiseql_error(&err);
509        assert_eq!(graphql_err.code, ErrorCode::Forbidden);
510    }
511
512    #[test]
513    fn test_from_fraiseql_error_authentication_maps_to_unauthenticated() {
514        use fraiseql_core::error::FraiseQLError;
515        let err = FraiseQLError::Authentication {
516            message: "token expired".into(),
517        };
518        let graphql_err = GraphQLError::from_fraiseql_error(&err);
519        assert_eq!(graphql_err.code, ErrorCode::Unauthenticated);
520    }
521
522    #[test]
523    fn test_error_extensions() {
524        let extensions = ErrorExtensions {
525            category:         Some("VALIDATION".to_string()),
526            status:           Some(400),
527            request_id:       Some("req-123".to_string()),
528            retry_after_secs: None,
529            detail:           None,
530        };
531
532        let error = GraphQLError::validation("Invalid").with_extensions(extensions);
533        let json = serde_json::to_string(&error).unwrap();
534        assert!(json.contains("VALIDATION"));
535        assert!(json.contains("req-123"));
536    }
537
538    // =========================================================================
539    // Complete ErrorCode → HTTP status coverage
540    // =========================================================================
541
542    #[test]
543    fn test_all_error_codes_have_expected_status() {
544        // Every variant must map to an explicit HTTP status
545        // GraphQL-over-HTTP spec §7.1.2: parse/validation errors on well-formed requests → 200
546        assert_eq!(ErrorCode::ParseError.status_code(), StatusCode::OK);
547        assert_eq!(ErrorCode::RequestError.status_code(), StatusCode::BAD_REQUEST);
548        assert_eq!(ErrorCode::NotFound.status_code(), StatusCode::NOT_FOUND);
549        assert_eq!(ErrorCode::Conflict.status_code(), StatusCode::CONFLICT);
550        assert_eq!(ErrorCode::RateLimitExceeded.status_code(), StatusCode::TOO_MANY_REQUESTS);
551        assert_eq!(ErrorCode::Timeout.status_code(), StatusCode::REQUEST_TIMEOUT);
552        assert_eq!(ErrorCode::InternalServerError.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
553        assert_eq!(ErrorCode::PersistedQueryMismatch.status_code(), StatusCode::BAD_REQUEST);
554        assert_eq!(ErrorCode::ForbiddenQuery.status_code(), StatusCode::BAD_REQUEST);
555        assert_eq!(ErrorCode::DocumentNotFound.status_code(), StatusCode::BAD_REQUEST);
556    }
557
558    #[test]
559    fn test_persisted_query_not_found_maps_to_200() {
560        // APQ protocol: "not found" signals the client to re-send with the full query body.
561        // Returning 200 OK is spec-required for the APQ flow.
562        assert_eq!(ErrorCode::PersistedQueryNotFound.status_code(), StatusCode::OK);
563
564        use axum::response::IntoResponse;
565        let response =
566            ErrorResponse::from_error(GraphQLError::persisted_query_not_found()).into_response();
567        assert_eq!(response.status(), StatusCode::OK);
568    }
569
570    // =========================================================================
571    // FraiseQLError → GraphQLError conversion coverage
572    // =========================================================================
573
574    #[test]
575    fn test_from_fraiseql_timeout_maps_to_timeout_code() {
576        use fraiseql_core::error::FraiseQLError;
577        let err = FraiseQLError::Timeout {
578            timeout_ms: 5000,
579            query:      Some("{ users { id } }".into()),
580        };
581        let graphql_err = GraphQLError::from_fraiseql_error(&err);
582        assert_eq!(graphql_err.code, ErrorCode::Timeout);
583    }
584
585    #[test]
586    fn test_from_fraiseql_rate_limited_maps_to_rate_limit_code() {
587        use fraiseql_core::error::FraiseQLError;
588        let err = FraiseQLError::RateLimited {
589            message:          "too many requests".into(),
590            retry_after_secs: 60,
591        };
592        let graphql_err = GraphQLError::from_fraiseql_error(&err);
593        assert_eq!(graphql_err.code, ErrorCode::RateLimitExceeded);
594    }
595
596    #[test]
597    fn test_from_fraiseql_conflict_maps_to_conflict_code() {
598        use fraiseql_core::error::FraiseQLError;
599        let err = FraiseQLError::Conflict {
600            message: "unique constraint violated".into(),
601        };
602        let graphql_err = GraphQLError::from_fraiseql_error(&err);
603        assert_eq!(graphql_err.code, ErrorCode::Conflict);
604    }
605
606    #[test]
607    fn test_from_fraiseql_parse_maps_to_parse_code() {
608        use fraiseql_core::error::FraiseQLError;
609        let err = FraiseQLError::Parse {
610            message:  "unexpected token".into(),
611            location: "line 1, col 5".into(),
612        };
613        let graphql_err = GraphQLError::from_fraiseql_error(&err);
614        assert_eq!(graphql_err.code, ErrorCode::ParseError);
615    }
616
617    #[test]
618    fn test_from_fraiseql_internal_maps_to_internal_code() {
619        use fraiseql_core::error::FraiseQLError;
620        let err = FraiseQLError::Internal {
621            message: "unexpected nil pointer".into(),
622            source:  None,
623        };
624        let graphql_err = GraphQLError::from_fraiseql_error(&err);
625        assert_eq!(graphql_err.code, ErrorCode::InternalServerError);
626    }
627
628    // =========================================================================
629    // HTTP response structure tests
630    // =========================================================================
631
632    #[test]
633    fn test_timeout_response_has_correct_status() {
634        use axum::response::IntoResponse;
635        let response =
636            ErrorResponse::from_error(GraphQLError::new("timed out", ErrorCode::Timeout))
637                .into_response();
638        assert_eq!(response.status(), StatusCode::REQUEST_TIMEOUT);
639    }
640
641    #[test]
642    fn test_rate_limit_response_has_correct_status() {
643        use axum::response::IntoResponse;
644        let response = ErrorResponse::from_error(GraphQLError::rate_limited("too many requests"))
645            .into_response();
646        assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
647    }
648
649    #[test]
650    fn test_not_found_response_has_correct_status() {
651        use axum::response::IntoResponse;
652        let response = ErrorResponse::from_error(GraphQLError::not_found("resource not found"))
653            .into_response();
654        assert_eq!(response.status(), StatusCode::NOT_FOUND);
655    }
656
657    // =========================================================================
658    // GraphQL-over-HTTP spec compliance: validation/parse errors → 200
659    // =========================================================================
660
661    /// Complexity and depth rejections must return HTTP 200, not 400.
662    ///
663    /// Per GraphQL-over-HTTP spec §7.1.2, a well-formed request that fails GraphQL
664    /// validation (including complexity/depth limits) must produce a 200 response
665    /// with `{"errors": [...]}`.  Returning 400 causes standard HTTP clients
666    /// (urllib, fetch, axios) to raise exceptions rather than reading the error body,
667    /// making it impossible to distinguish a transport failure from a validation failure.
668    #[test]
669    fn test_complexity_rejection_returns_200() {
670        use axum::response::IntoResponse;
671        let response = ErrorResponse::from_error(GraphQLError::validation(
672            "Query exceeds maximum complexity: 121 > 100",
673        ))
674        .into_response();
675        assert_eq!(
676            response.status(),
677            StatusCode::OK,
678            "complexity validation errors must return HTTP 200 per GraphQL-over-HTTP spec"
679        );
680    }
681
682    #[test]
683    fn test_depth_rejection_returns_200() {
684        use axum::response::IntoResponse;
685        let response = ErrorResponse::from_error(GraphQLError::validation(
686            "Query exceeds maximum depth: 16 > 15",
687        ))
688        .into_response();
689        assert_eq!(
690            response.status(),
691            StatusCode::OK,
692            "depth validation errors must return HTTP 200 per GraphQL-over-HTTP spec"
693        );
694    }
695
696    #[test]
697    fn test_parse_error_returns_200() {
698        use axum::response::IntoResponse;
699        let response =
700            ErrorResponse::from_error(GraphQLError::parse("unexpected token '}'")).into_response();
701        assert_eq!(
702            response.status(),
703            StatusCode::OK,
704            "GraphQL parse errors must return HTTP 200 per GraphQL-over-HTTP spec"
705        );
706    }
707}