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 BadUserInput,
37 ConstraintViolation,
40 Timeout,
42 RateLimitExceeded,
44 NotFound,
46 Conflict,
48 CircuitBreakerOpen,
50 ServiceUnavailable,
53 PersistedQueryNotFound,
55 PersistedQueryMismatch,
57 ForbiddenQuery,
59 DocumentNotFound,
61 MethodNotAllowed,
63 IntrospectionDisabled,
68}
69
70impl ErrorCode {
71 #[must_use]
82 pub const fn status_code(self) -> StatusCode {
83 match self {
84 Self::ValidationError
88 | Self::ParseError
89 | Self::PersistedQueryNotFound
90 | Self::IntrospectionDisabled => StatusCode::OK,
91 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 Self::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
110 }
111 }
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub(crate) enum ClientInputSqlState {
121 DataException,
124 IntegrityConstraint,
127}
128
129pub(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#[derive(Debug, Clone, Serialize)]
145pub struct ErrorLocation {
146 pub line: usize,
148 pub column: usize,
150}
151
152#[derive(Debug, Clone, Serialize)]
154pub struct GraphQLError {
155 pub message: String,
157
158 pub code: ErrorCode,
160
161 #[serde(skip_serializing_if = "Option::is_none")]
163 pub locations: Option<Vec<ErrorLocation>>,
164
165 #[serde(skip_serializing_if = "Option::is_none")]
167 pub path: Option<Vec<String>>,
168
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub extensions: Option<ErrorExtensions>,
172}
173
174#[derive(Debug, Clone, Serialize)]
176pub struct ErrorExtensions {
177 #[serde(skip_serializing_if = "Option::is_none")]
179 pub category: Option<String>,
180
181 #[serde(skip_serializing_if = "Option::is_none")]
183 pub status: Option<u16>,
184
185 #[serde(skip_serializing_if = "Option::is_none")]
187 pub request_id: Option<String>,
188
189 #[serde(skip_serializing_if = "Option::is_none")]
191 pub retry_after_secs: Option<u64>,
192
193 #[serde(skip_serializing_if = "Option::is_none")]
197 pub detail: Option<String>,
198}
199
200#[derive(Debug, Serialize)]
202pub struct ErrorResponse {
203 pub errors: Vec<GraphQLError>,
205}
206
207impl GraphQLError {
208 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 #[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 #[must_use]
228 pub fn with_path(mut self, path: Vec<String>) -> Self {
229 self.path = Some(path);
230 self
231 }
232
233 #[must_use]
235 pub fn with_extensions(mut self, extensions: ErrorExtensions) -> Self {
236 self.extensions = Some(extensions);
237 self
238 }
239
240 #[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 pub fn validation(message: impl Into<String>) -> Self {
261 Self::new(message, ErrorCode::ValidationError)
262 }
263
264 pub fn parse(message: impl Into<String>) -> Self {
266 Self::new(message, ErrorCode::ParseError)
267 }
268
269 pub fn request(message: impl Into<String>) -> Self {
271 Self::new(message, ErrorCode::RequestError)
272 }
273
274 pub fn method_not_allowed(message: impl Into<String>) -> Self {
276 Self::new(message, ErrorCode::MethodNotAllowed)
277 }
278
279 pub fn database(message: impl Into<String>) -> Self {
281 Self::new(message, ErrorCode::DatabaseError)
282 }
283
284 pub fn internal(message: impl Into<String>) -> Self {
286 Self::new(message, ErrorCode::InternalServerError)
287 }
288
289 #[doc(hidden)]
298 #[must_use]
299 pub fn execution(message: &str) -> Self {
300 Self::new(message, ErrorCode::InternalServerError)
301 }
302
303 #[must_use]
305 pub fn unauthenticated() -> Self {
306 Self::new("Authentication required", ErrorCode::Unauthenticated)
307 }
308
309 #[must_use]
311 pub fn forbidden() -> Self {
312 Self::new("Access denied", ErrorCode::Forbidden)
313 }
314
315 pub fn not_found(message: impl Into<String>) -> Self {
317 Self::new(message, ErrorCode::NotFound)
318 }
319
320 pub fn timeout(operation: impl Into<String>) -> Self {
322 Self::new(format!("{} exceeded timeout", operation.into()), ErrorCode::Timeout)
323 }
324
325 pub fn rate_limited(message: impl Into<String>) -> Self {
327 Self::new(message, ErrorCode::RateLimitExceeded)
328 }
329
330 #[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 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 _ => Self::internal(err.to_string()),
366 }
367 }
368
369 #[must_use]
371 pub fn persisted_query_not_found() -> Self {
372 Self::new("PersistedQueryNotFound", ErrorCode::PersistedQueryNotFound)
373 }
374
375 #[must_use]
377 pub fn persisted_query_mismatch() -> Self {
378 Self::new("provided sha does not match query", ErrorCode::PersistedQueryMismatch)
379 }
380
381 #[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 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 #[must_use]
400 pub fn introspection_disabled(detail: impl Into<String>) -> Self {
401 Self::new(detail, ErrorCode::IntrospectionDisabled)
402 }
403
404 #[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 #[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 #[must_use]
444 pub const fn new(errors: Vec<GraphQLError>) -> Self {
445 Self { errors }
446 }
447
448 #[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}