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}
64
65impl ErrorCode {
66 #[must_use]
77 pub const fn status_code(self) -> StatusCode {
78 match self {
79 Self::ValidationError | Self::ParseError | Self::PersistedQueryNotFound => {
83 StatusCode::OK
84 },
85 Self::RequestError
88 | Self::PersistedQueryMismatch
89 | Self::ForbiddenQuery
90 | Self::DocumentNotFound
91 | Self::BadUserInput
92 | Self::ConstraintViolation => StatusCode::BAD_REQUEST,
93 Self::Unauthenticated => StatusCode::UNAUTHORIZED,
94 Self::Forbidden => StatusCode::FORBIDDEN,
95 Self::NotFound => StatusCode::NOT_FOUND,
96 Self::Conflict => StatusCode::CONFLICT,
97 Self::RateLimitExceeded => StatusCode::TOO_MANY_REQUESTS,
98 Self::Timeout => StatusCode::REQUEST_TIMEOUT,
99 Self::InternalServerError | Self::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
100 Self::CircuitBreakerOpen | Self::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE,
101 Self::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
104 }
105 }
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub(crate) enum ClientInputSqlState {
115 DataException,
118 IntegrityConstraint,
121}
122
123pub(crate) fn classify_client_input_sqlstate(
128 sql_state: Option<&str>,
129) -> Option<ClientInputSqlState> {
130 match sql_state {
131 Some(s) if s.starts_with("22") => Some(ClientInputSqlState::DataException),
132 Some(s) if s.starts_with("23") => Some(ClientInputSqlState::IntegrityConstraint),
133 _ => None,
134 }
135}
136
137#[derive(Debug, Clone, Serialize)]
139pub struct ErrorLocation {
140 pub line: usize,
142 pub column: usize,
144}
145
146#[derive(Debug, Clone, Serialize)]
148pub struct GraphQLError {
149 pub message: String,
151
152 pub code: ErrorCode,
154
155 #[serde(skip_serializing_if = "Option::is_none")]
157 pub locations: Option<Vec<ErrorLocation>>,
158
159 #[serde(skip_serializing_if = "Option::is_none")]
161 pub path: Option<Vec<String>>,
162
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub extensions: Option<ErrorExtensions>,
166}
167
168#[derive(Debug, Clone, Serialize)]
170pub struct ErrorExtensions {
171 #[serde(skip_serializing_if = "Option::is_none")]
173 pub category: Option<String>,
174
175 #[serde(skip_serializing_if = "Option::is_none")]
177 pub status: Option<u16>,
178
179 #[serde(skip_serializing_if = "Option::is_none")]
181 pub request_id: Option<String>,
182
183 #[serde(skip_serializing_if = "Option::is_none")]
185 pub retry_after_secs: Option<u64>,
186
187 #[serde(skip_serializing_if = "Option::is_none")]
191 pub detail: Option<String>,
192}
193
194#[derive(Debug, Serialize)]
196pub struct ErrorResponse {
197 pub errors: Vec<GraphQLError>,
199}
200
201impl GraphQLError {
202 pub fn new(message: impl Into<String>, code: ErrorCode) -> Self {
204 Self {
205 message: message.into(),
206 code,
207 locations: None,
208 path: None,
209 extensions: None,
210 }
211 }
212
213 #[must_use]
215 pub fn with_location(mut self, line: usize, column: usize) -> Self {
216 self.locations = Some(vec![ErrorLocation { line, column }]);
217 self
218 }
219
220 #[must_use]
222 pub fn with_path(mut self, path: Vec<String>) -> Self {
223 self.path = Some(path);
224 self
225 }
226
227 #[must_use]
229 pub fn with_extensions(mut self, extensions: ErrorExtensions) -> Self {
230 self.extensions = Some(extensions);
231 self
232 }
233
234 #[must_use]
236 pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
237 let request_id = request_id.into();
238 let extensions = self.extensions.take().unwrap_or(ErrorExtensions {
239 category: None,
240 status: None,
241 request_id: None,
242 retry_after_secs: None,
243 detail: None,
244 });
245
246 self.extensions = Some(ErrorExtensions {
247 request_id: Some(request_id),
248 ..extensions
249 });
250 self
251 }
252
253 pub fn validation(message: impl Into<String>) -> Self {
255 Self::new(message, ErrorCode::ValidationError)
256 }
257
258 pub fn parse(message: impl Into<String>) -> Self {
260 Self::new(message, ErrorCode::ParseError)
261 }
262
263 pub fn request(message: impl Into<String>) -> Self {
265 Self::new(message, ErrorCode::RequestError)
266 }
267
268 pub fn method_not_allowed(message: impl Into<String>) -> Self {
270 Self::new(message, ErrorCode::MethodNotAllowed)
271 }
272
273 pub fn database(message: impl Into<String>) -> Self {
275 Self::new(message, ErrorCode::DatabaseError)
276 }
277
278 pub fn internal(message: impl Into<String>) -> Self {
280 Self::new(message, ErrorCode::InternalServerError)
281 }
282
283 #[doc(hidden)]
292 #[must_use]
293 pub fn execution(message: &str) -> Self {
294 Self::new(message, ErrorCode::InternalServerError)
295 }
296
297 #[must_use]
299 pub fn unauthenticated() -> Self {
300 Self::new("Authentication required", ErrorCode::Unauthenticated)
301 }
302
303 #[must_use]
305 pub fn forbidden() -> Self {
306 Self::new("Access denied", ErrorCode::Forbidden)
307 }
308
309 pub fn not_found(message: impl Into<String>) -> Self {
311 Self::new(message, ErrorCode::NotFound)
312 }
313
314 pub fn timeout(operation: impl Into<String>) -> Self {
316 Self::new(format!("{} exceeded timeout", operation.into()), ErrorCode::Timeout)
317 }
318
319 pub fn rate_limited(message: impl Into<String>) -> Self {
321 Self::new(message, ErrorCode::RateLimitExceeded)
322 }
323
324 #[must_use]
330 pub fn from_fraiseql_error(err: &fraiseql_core::error::FraiseQLError) -> Self {
331 use fraiseql_core::error::FraiseQLError as E;
332 match err {
333 E::Database { sql_state, .. } => {
337 match classify_client_input_sqlstate(sql_state.as_deref()) {
338 Some(ClientInputSqlState::DataException) => {
339 Self::new(err.to_string(), ErrorCode::BadUserInput)
340 },
341 Some(ClientInputSqlState::IntegrityConstraint) => {
342 Self::new(err.to_string(), ErrorCode::ConstraintViolation)
343 },
344 None => Self::database(err.to_string()),
345 }
346 },
347 E::ConnectionPool { .. } => Self::database(err.to_string()),
348 E::Parse { .. } => Self::parse(err.to_string()),
349 E::Validation { .. } | E::UnknownField { .. } | E::UnknownType { .. } => {
350 Self::validation(err.to_string())
351 },
352 E::NotFound { .. } => Self::not_found(err.to_string()),
353 E::Conflict { .. } => Self::new(err.to_string(), ErrorCode::Conflict),
354 E::Authorization { .. } => Self::forbidden(),
355 E::Authentication { .. } => Self::unauthenticated(),
356 E::Timeout { .. } => Self::new(err.to_string(), ErrorCode::Timeout),
357 E::RateLimited { message, .. } => Self::rate_limited(message.clone()),
358 _ => Self::internal(err.to_string()),
360 }
361 }
362
363 #[must_use]
365 pub fn persisted_query_not_found() -> Self {
366 Self::new("PersistedQueryNotFound", ErrorCode::PersistedQueryNotFound)
367 }
368
369 #[must_use]
371 pub fn persisted_query_mismatch() -> Self {
372 Self::new("provided sha does not match query", ErrorCode::PersistedQueryMismatch)
373 }
374
375 #[must_use]
377 pub fn forbidden_query() -> Self {
378 Self::new(
379 "Raw queries are not permitted. Send a documentId instead.",
380 ErrorCode::ForbiddenQuery,
381 )
382 }
383
384 pub fn document_not_found(doc_id: impl Into<String>) -> Self {
386 Self::new(format!("Unknown document: {}", doc_id.into()), ErrorCode::DocumentNotFound)
387 }
388
389 #[must_use]
393 pub fn circuit_breaker_open(entity: &str, retry_after_secs: u64) -> Self {
394 Self::new(
395 format!(
396 "Federation entity '{entity}' is temporarily unavailable. \
397 Please retry after {retry_after_secs} seconds."
398 ),
399 ErrorCode::CircuitBreakerOpen,
400 )
401 .with_extensions(ErrorExtensions {
402 category: Some("CIRCUIT_BREAKER".to_string()),
403 status: Some(503),
404 request_id: None,
405 retry_after_secs: Some(retry_after_secs),
406 detail: None,
407 })
408 }
409
410 #[must_use]
415 pub fn service_unavailable(message: impl Into<String>, retry_after_secs: Option<u64>) -> Self {
416 Self::new(message, ErrorCode::ServiceUnavailable).with_extensions(ErrorExtensions {
417 category: Some("SERVICE_UNAVAILABLE".to_string()),
418 status: Some(503),
419 request_id: None,
420 retry_after_secs,
421 detail: None,
422 })
423 }
424}
425
426impl ErrorResponse {
427 #[must_use]
429 pub const fn new(errors: Vec<GraphQLError>) -> Self {
430 Self { errors }
431 }
432
433 #[must_use]
435 pub fn from_error(error: GraphQLError) -> Self {
436 Self {
437 errors: vec![error],
438 }
439 }
440}
441
442impl IntoResponse for ErrorResponse {
443 fn into_response(self) -> Response {
444 let status = self
445 .errors
446 .first()
447 .map_or(StatusCode::INTERNAL_SERVER_ERROR, |e| e.code.status_code());
448
449 let retry_after = self
450 .errors
451 .first()
452 .and_then(|e| e.extensions.as_ref())
453 .and_then(|ext| ext.retry_after_secs);
454
455 let mut response = (status, Json(self)).into_response();
456
457 if let Some(secs) = retry_after {
458 if let Ok(value) = secs.to_string().parse() {
459 response.headers_mut().insert(axum::http::header::RETRY_AFTER, value);
460 }
461 }
462
463 response
464 }
465}
466
467impl From<GraphQLError> for ErrorResponse {
468 fn from(error: GraphQLError) -> Self {
469 Self::from_error(error)
470 }
471}