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}
62
63impl ErrorCode {
64 #[must_use]
75 pub const fn status_code(self) -> StatusCode {
76 match self {
77 Self::ValidationError | Self::ParseError | Self::PersistedQueryNotFound => {
81 StatusCode::OK
82 },
83 Self::RequestError
86 | Self::PersistedQueryMismatch
87 | Self::ForbiddenQuery
88 | Self::DocumentNotFound
89 | Self::BadUserInput
90 | Self::ConstraintViolation => StatusCode::BAD_REQUEST,
91 Self::Unauthenticated => StatusCode::UNAUTHORIZED,
92 Self::Forbidden => StatusCode::FORBIDDEN,
93 Self::NotFound => StatusCode::NOT_FOUND,
94 Self::Conflict => StatusCode::CONFLICT,
95 Self::RateLimitExceeded => StatusCode::TOO_MANY_REQUESTS,
96 Self::Timeout => StatusCode::REQUEST_TIMEOUT,
97 Self::InternalServerError | Self::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
98 Self::CircuitBreakerOpen | Self::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE,
99 }
100 }
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub(crate) enum ClientInputSqlState {
110 DataException,
113 IntegrityConstraint,
116}
117
118pub(crate) fn classify_client_input_sqlstate(
123 sql_state: Option<&str>,
124) -> Option<ClientInputSqlState> {
125 match sql_state {
126 Some(s) if s.starts_with("22") => Some(ClientInputSqlState::DataException),
127 Some(s) if s.starts_with("23") => Some(ClientInputSqlState::IntegrityConstraint),
128 _ => None,
129 }
130}
131
132#[derive(Debug, Clone, Serialize)]
134pub struct ErrorLocation {
135 pub line: usize,
137 pub column: usize,
139}
140
141#[derive(Debug, Clone, Serialize)]
143pub struct GraphQLError {
144 pub message: String,
146
147 pub code: ErrorCode,
149
150 #[serde(skip_serializing_if = "Option::is_none")]
152 pub locations: Option<Vec<ErrorLocation>>,
153
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub path: Option<Vec<String>>,
157
158 #[serde(skip_serializing_if = "Option::is_none")]
160 pub extensions: Option<ErrorExtensions>,
161}
162
163#[derive(Debug, Clone, Serialize)]
165pub struct ErrorExtensions {
166 #[serde(skip_serializing_if = "Option::is_none")]
168 pub category: Option<String>,
169
170 #[serde(skip_serializing_if = "Option::is_none")]
172 pub status: Option<u16>,
173
174 #[serde(skip_serializing_if = "Option::is_none")]
176 pub request_id: Option<String>,
177
178 #[serde(skip_serializing_if = "Option::is_none")]
180 pub retry_after_secs: Option<u64>,
181
182 #[serde(skip_serializing_if = "Option::is_none")]
186 pub detail: Option<String>,
187}
188
189#[derive(Debug, Serialize)]
191pub struct ErrorResponse {
192 pub errors: Vec<GraphQLError>,
194}
195
196impl GraphQLError {
197 pub fn new(message: impl Into<String>, code: ErrorCode) -> Self {
199 Self {
200 message: message.into(),
201 code,
202 locations: None,
203 path: None,
204 extensions: None,
205 }
206 }
207
208 #[must_use]
210 pub fn with_location(mut self, line: usize, column: usize) -> Self {
211 self.locations = Some(vec![ErrorLocation { line, column }]);
212 self
213 }
214
215 #[must_use]
217 pub fn with_path(mut self, path: Vec<String>) -> Self {
218 self.path = Some(path);
219 self
220 }
221
222 #[must_use]
224 pub fn with_extensions(mut self, extensions: ErrorExtensions) -> Self {
225 self.extensions = Some(extensions);
226 self
227 }
228
229 #[must_use]
231 pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
232 let request_id = request_id.into();
233 let extensions = self.extensions.take().unwrap_or(ErrorExtensions {
234 category: None,
235 status: None,
236 request_id: None,
237 retry_after_secs: None,
238 detail: None,
239 });
240
241 self.extensions = Some(ErrorExtensions {
242 request_id: Some(request_id),
243 ..extensions
244 });
245 self
246 }
247
248 pub fn validation(message: impl Into<String>) -> Self {
250 Self::new(message, ErrorCode::ValidationError)
251 }
252
253 pub fn parse(message: impl Into<String>) -> Self {
255 Self::new(message, ErrorCode::ParseError)
256 }
257
258 pub fn request(message: impl Into<String>) -> Self {
260 Self::new(message, ErrorCode::RequestError)
261 }
262
263 pub fn database(message: impl Into<String>) -> Self {
265 Self::new(message, ErrorCode::DatabaseError)
266 }
267
268 pub fn internal(message: impl Into<String>) -> Self {
270 Self::new(message, ErrorCode::InternalServerError)
271 }
272
273 #[doc(hidden)]
282 #[must_use]
283 pub fn execution(message: &str) -> Self {
284 Self::new(message, ErrorCode::InternalServerError)
285 }
286
287 #[must_use]
289 pub fn unauthenticated() -> Self {
290 Self::new("Authentication required", ErrorCode::Unauthenticated)
291 }
292
293 #[must_use]
295 pub fn forbidden() -> Self {
296 Self::new("Access denied", ErrorCode::Forbidden)
297 }
298
299 pub fn not_found(message: impl Into<String>) -> Self {
301 Self::new(message, ErrorCode::NotFound)
302 }
303
304 pub fn timeout(operation: impl Into<String>) -> Self {
306 Self::new(format!("{} exceeded timeout", operation.into()), ErrorCode::Timeout)
307 }
308
309 pub fn rate_limited(message: impl Into<String>) -> Self {
311 Self::new(message, ErrorCode::RateLimitExceeded)
312 }
313
314 #[must_use]
320 pub fn from_fraiseql_error(err: &fraiseql_core::error::FraiseQLError) -> Self {
321 use fraiseql_core::error::FraiseQLError as E;
322 match err {
323 E::Database { sql_state, .. } => {
327 match classify_client_input_sqlstate(sql_state.as_deref()) {
328 Some(ClientInputSqlState::DataException) => {
329 Self::new(err.to_string(), ErrorCode::BadUserInput)
330 },
331 Some(ClientInputSqlState::IntegrityConstraint) => {
332 Self::new(err.to_string(), ErrorCode::ConstraintViolation)
333 },
334 None => Self::database(err.to_string()),
335 }
336 },
337 E::ConnectionPool { .. } => Self::database(err.to_string()),
338 E::Parse { .. } => Self::parse(err.to_string()),
339 E::Validation { .. } | E::UnknownField { .. } | E::UnknownType { .. } => {
340 Self::validation(err.to_string())
341 },
342 E::NotFound { .. } => Self::not_found(err.to_string()),
343 E::Conflict { .. } => Self::new(err.to_string(), ErrorCode::Conflict),
344 E::Authorization { .. } => Self::forbidden(),
345 E::Authentication { .. } => Self::unauthenticated(),
346 E::Timeout { .. } => Self::new(err.to_string(), ErrorCode::Timeout),
347 E::RateLimited { message, .. } => Self::rate_limited(message.clone()),
348 _ => Self::internal(err.to_string()),
350 }
351 }
352
353 #[must_use]
355 pub fn persisted_query_not_found() -> Self {
356 Self::new("PersistedQueryNotFound", ErrorCode::PersistedQueryNotFound)
357 }
358
359 #[must_use]
361 pub fn persisted_query_mismatch() -> Self {
362 Self::new("provided sha does not match query", ErrorCode::PersistedQueryMismatch)
363 }
364
365 #[must_use]
367 pub fn forbidden_query() -> Self {
368 Self::new(
369 "Raw queries are not permitted. Send a documentId instead.",
370 ErrorCode::ForbiddenQuery,
371 )
372 }
373
374 pub fn document_not_found(doc_id: impl Into<String>) -> Self {
376 Self::new(format!("Unknown document: {}", doc_id.into()), ErrorCode::DocumentNotFound)
377 }
378
379 #[must_use]
383 pub fn circuit_breaker_open(entity: &str, retry_after_secs: u64) -> Self {
384 Self::new(
385 format!(
386 "Federation entity '{entity}' is temporarily unavailable. \
387 Please retry after {retry_after_secs} seconds."
388 ),
389 ErrorCode::CircuitBreakerOpen,
390 )
391 .with_extensions(ErrorExtensions {
392 category: Some("CIRCUIT_BREAKER".to_string()),
393 status: Some(503),
394 request_id: None,
395 retry_after_secs: Some(retry_after_secs),
396 detail: None,
397 })
398 }
399
400 #[must_use]
405 pub fn service_unavailable(message: impl Into<String>, retry_after_secs: Option<u64>) -> Self {
406 Self::new(message, ErrorCode::ServiceUnavailable).with_extensions(ErrorExtensions {
407 category: Some("SERVICE_UNAVAILABLE".to_string()),
408 status: Some(503),
409 request_id: None,
410 retry_after_secs,
411 detail: None,
412 })
413 }
414}
415
416impl ErrorResponse {
417 #[must_use]
419 pub const fn new(errors: Vec<GraphQLError>) -> Self {
420 Self { errors }
421 }
422
423 #[must_use]
425 pub fn from_error(error: GraphQLError) -> Self {
426 Self {
427 errors: vec![error],
428 }
429 }
430}
431
432impl IntoResponse for ErrorResponse {
433 fn into_response(self) -> Response {
434 let status = self
435 .errors
436 .first()
437 .map_or(StatusCode::INTERNAL_SERVER_ERROR, |e| e.code.status_code());
438
439 let retry_after = self
440 .errors
441 .first()
442 .and_then(|e| e.extensions.as_ref())
443 .and_then(|ext| ext.retry_after_secs);
444
445 let mut response = (status, Json(self)).into_response();
446
447 if let Some(secs) = retry_after {
448 if let Ok(value) = secs.to_string().parse() {
449 response.headers_mut().insert(axum::http::header::RETRY_AFTER, value);
450 }
451 }
452
453 response
454 }
455}
456
457impl From<GraphQLError> for ErrorResponse {
458 fn from(error: GraphQLError) -> Self {
459 Self::from_error(error)
460 }
461}