1use axum::{
9 Json,
10 http::StatusCode,
11 response::{IntoResponse, Response},
12};
13use serde::Serialize;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
17#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
18#[non_exhaustive]
19pub enum ErrorCode {
20 ValidationError,
22 ParseError,
24 RequestError,
26 Unauthenticated,
28 Forbidden,
30 InternalServerError,
32 DatabaseError,
34 Timeout,
36 RateLimitExceeded,
38 NotFound,
40 Conflict,
42 CircuitBreakerOpen,
44 PersistedQueryNotFound,
46 PersistedQueryMismatch,
48 ForbiddenQuery,
50 DocumentNotFound,
52}
53
54impl ErrorCode {
55 #[must_use]
66 pub const fn status_code(self) -> StatusCode {
67 match self {
68 Self::ValidationError | Self::ParseError | Self::PersistedQueryNotFound => {
72 StatusCode::OK
73 },
74 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#[derive(Debug, Clone, Serialize)]
94pub struct ErrorLocation {
95 pub line: usize,
97 pub column: usize,
99}
100
101#[derive(Debug, Clone, Serialize)]
103pub struct GraphQLError {
104 pub message: String,
106
107 pub code: ErrorCode,
109
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub locations: Option<Vec<ErrorLocation>>,
113
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub path: Option<Vec<String>>,
117
118 #[serde(skip_serializing_if = "Option::is_none")]
120 pub extensions: Option<ErrorExtensions>,
121}
122
123#[derive(Debug, Clone, Serialize)]
125pub struct ErrorExtensions {
126 #[serde(skip_serializing_if = "Option::is_none")]
128 pub category: Option<String>,
129
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub status: Option<u16>,
133
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub request_id: Option<String>,
137
138 #[serde(skip_serializing_if = "Option::is_none")]
140 pub retry_after_secs: Option<u64>,
141
142 #[serde(skip_serializing_if = "Option::is_none")]
146 pub detail: Option<String>,
147}
148
149#[derive(Debug, Serialize)]
151pub struct ErrorResponse {
152 pub errors: Vec<GraphQLError>,
154}
155
156impl GraphQLError {
157 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 #[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 #[must_use]
177 pub fn with_path(mut self, path: Vec<String>) -> Self {
178 self.path = Some(path);
179 self
180 }
181
182 #[must_use]
184 pub fn with_extensions(mut self, extensions: ErrorExtensions) -> Self {
185 self.extensions = Some(extensions);
186 self
187 }
188
189 #[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 pub fn validation(message: impl Into<String>) -> Self {
210 Self::new(message, ErrorCode::ValidationError)
211 }
212
213 pub fn parse(message: impl Into<String>) -> Self {
215 Self::new(message, ErrorCode::ParseError)
216 }
217
218 pub fn request(message: impl Into<String>) -> Self {
220 Self::new(message, ErrorCode::RequestError)
221 }
222
223 pub fn database(message: impl Into<String>) -> Self {
225 Self::new(message, ErrorCode::DatabaseError)
226 }
227
228 pub fn internal(message: impl Into<String>) -> Self {
230 Self::new(message, ErrorCode::InternalServerError)
231 }
232
233 #[doc(hidden)]
242 #[must_use]
243 pub fn execution(message: &str) -> Self {
244 Self::new(message, ErrorCode::InternalServerError)
245 }
246
247 #[must_use]
249 pub fn unauthenticated() -> Self {
250 Self::new("Authentication required", ErrorCode::Unauthenticated)
251 }
252
253 #[must_use]
255 pub fn forbidden() -> Self {
256 Self::new("Access denied", ErrorCode::Forbidden)
257 }
258
259 pub fn not_found(message: impl Into<String>) -> Self {
261 Self::new(message, ErrorCode::NotFound)
262 }
263
264 pub fn timeout(operation: impl Into<String>) -> Self {
266 Self::new(format!("{} exceeded timeout", operation.into()), ErrorCode::Timeout)
267 }
268
269 pub fn rate_limited(message: impl Into<String>) -> Self {
271 Self::new(message, ErrorCode::RateLimitExceeded)
272 }
273
274 #[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 _ => Self::internal(err.to_string()),
296 }
297 }
298
299 #[must_use]
301 pub fn persisted_query_not_found() -> Self {
302 Self::new("PersistedQueryNotFound", ErrorCode::PersistedQueryNotFound)
303 }
304
305 #[must_use]
307 pub fn persisted_query_mismatch() -> Self {
308 Self::new("provided sha does not match query", ErrorCode::PersistedQueryMismatch)
309 }
310
311 #[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 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 #[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 #[must_use]
350 pub const fn new(errors: Vec<GraphQLError>) -> Self {
351 Self { errors }
352 }
353
354 #[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)] #![allow(clippy::cast_precision_loss)] #![allow(clippy::cast_sign_loss)] #![allow(clippy::cast_possible_truncation)] #![allow(clippy::cast_possible_wrap)] #![allow(clippy::missing_panics_doc)] #![allow(clippy::missing_errors_doc)] #![allow(missing_docs)] #![allow(clippy::items_after_statements)] 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 assert_eq!(ErrorCode::ValidationError.status_code(), StatusCode::OK);
436 assert_eq!(ErrorCode::ParseError.status_code(), StatusCode::OK);
437 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 #[test]
543 fn test_all_error_codes_have_expected_status() {
544 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 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 #[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 #[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 #[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}