Skip to main content

fraiseql_error/
http.rs

1use axum::{
2    Json,
3    http::StatusCode,
4    response::{IntoResponse, Response},
5};
6use serde::Serialize;
7
8use crate::{AuthError, FileError, RuntimeError, WebhookError};
9
10/// Error response format (consistent across all endpoints)
11#[derive(Debug, Serialize)]
12pub struct ErrorResponse {
13    pub error:             String,
14    pub error_description: String,
15    pub error_code:        String,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub error_uri:         Option<String>,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub details:           Option<serde_json::Value>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub retry_after:       Option<u64>,
22}
23
24impl ErrorResponse {
25    pub fn new(
26        error: impl Into<String>,
27        description: impl Into<String>,
28        code: impl Into<String>,
29    ) -> Self {
30        let code = code.into();
31        Self {
32            error:             error.into(),
33            error_description: description.into(),
34            error_uri:         Some(format!("https://docs.fraiseql.dev/errors#{}", code)),
35            error_code:        code,
36            details:           None,
37            retry_after:       None,
38        }
39    }
40
41    pub fn with_details(mut self, details: serde_json::Value) -> Self {
42        self.details = Some(details);
43        self
44    }
45
46    pub const fn with_retry_after(mut self, seconds: u64) -> Self {
47        self.retry_after = Some(seconds);
48        self
49    }
50}
51
52impl IntoResponse for RuntimeError {
53    fn into_response(self) -> Response {
54        let error_code = self.error_code();
55
56        let (status, response) = match &self {
57            RuntimeError::Config(_) => (
58                StatusCode::INTERNAL_SERVER_ERROR,
59                ErrorResponse::new("configuration_error", self.to_string(), error_code),
60            ),
61
62            RuntimeError::Auth(e) => {
63                let status = match e {
64                    AuthError::InsufficientPermissions { .. } | AuthError::AccountLocked { .. } => {
65                        StatusCode::FORBIDDEN
66                    },
67                    _ => StatusCode::UNAUTHORIZED,
68                };
69                (status, ErrorResponse::new("authentication_error", self.to_string(), error_code))
70            },
71
72            RuntimeError::Webhook(e) => {
73                let status = match e {
74                    WebhookError::InvalidSignature => StatusCode::UNAUTHORIZED,
75                    WebhookError::DuplicateEvent { .. } => StatusCode::OK,
76                    _ => StatusCode::BAD_REQUEST,
77                };
78                (status, ErrorResponse::new("webhook_error", self.to_string(), error_code))
79            },
80
81            RuntimeError::File(e) => {
82                let status = match e {
83                    FileError::TooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
84                    FileError::InvalidType { .. } | FileError::MimeMismatch { .. } => {
85                        StatusCode::UNSUPPORTED_MEDIA_TYPE
86                    },
87                    FileError::NotFound { .. } => StatusCode::NOT_FOUND,
88                    FileError::VirusDetected { .. } => StatusCode::UNPROCESSABLE_ENTITY,
89                    FileError::QuotaExceeded => StatusCode::INSUFFICIENT_STORAGE,
90                    _ => StatusCode::BAD_REQUEST,
91                };
92                (status, ErrorResponse::new("file_error", self.to_string(), error_code))
93            },
94
95            RuntimeError::Notification(e) => {
96                use crate::NotificationError::{
97                    CircuitOpen, InvalidInput, ProviderRateLimited, ProviderUnavailable,
98                };
99                let status = match e {
100                    CircuitOpen { .. } | ProviderUnavailable { .. } => {
101                        StatusCode::SERVICE_UNAVAILABLE
102                    },
103                    ProviderRateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
104                    InvalidInput { .. } => StatusCode::BAD_REQUEST,
105                    _ => StatusCode::INTERNAL_SERVER_ERROR,
106                };
107                (status, ErrorResponse::new("notification_error", self.to_string(), error_code))
108            },
109
110            RuntimeError::RateLimited { retry_after } => {
111                let mut resp =
112                    ErrorResponse::new("rate_limited", "Rate limit exceeded", error_code);
113                if let Some(secs) = retry_after {
114                    resp = resp.with_retry_after(*secs);
115                }
116                (StatusCode::TOO_MANY_REQUESTS, resp)
117            },
118
119            RuntimeError::ServiceUnavailable { retry_after, .. } => {
120                let mut resp =
121                    ErrorResponse::new("service_unavailable", self.to_string(), error_code);
122                if let Some(secs) = retry_after {
123                    resp = resp.with_retry_after(*secs);
124                }
125                (StatusCode::SERVICE_UNAVAILABLE, resp)
126            },
127
128            RuntimeError::NotFound { .. } => (
129                StatusCode::NOT_FOUND,
130                ErrorResponse::new("not_found", self.to_string(), error_code),
131            ),
132
133            RuntimeError::Database(_) => (
134                StatusCode::INTERNAL_SERVER_ERROR,
135                ErrorResponse::new("database_error", "A database error occurred", error_code),
136            ),
137
138            _ => (
139                StatusCode::INTERNAL_SERVER_ERROR,
140                ErrorResponse::new("internal_error", "An internal error occurred", error_code),
141            ),
142        };
143
144        // Add Retry-After header for rate limits
145        let mut resp = (status, Json(response)).into_response();
146        if let Some(retry_after) = self.retry_after_header() {
147            resp.headers_mut().insert("Retry-After", retry_after.parse().unwrap());
148        }
149
150        resp
151    }
152}
153
154impl RuntimeError {
155    fn retry_after_header(&self) -> Option<String> {
156        match self {
157            Self::RateLimited {
158                retry_after: Some(secs),
159            }
160            | Self::ServiceUnavailable {
161                retry_after: Some(secs),
162                ..
163            } => Some(secs.to_string()),
164            _ => None,
165        }
166    }
167}
168
169/// Trait to enable `?` operator in handlers
170pub trait IntoHttpResponse {
171    fn into_http_response(self) -> Response;
172}
173
174impl<T> IntoHttpResponse for Result<T, RuntimeError>
175where
176    T: IntoResponse,
177{
178    fn into_http_response(self) -> Response {
179        match self {
180            Ok(value) => value.into_response(),
181            Err(err) => err.into_response(),
182        }
183    }
184}