Skip to main content

autumn_plugin_graphql/
error.rs

1//! Error vocabulary and mapping.
2//!
3//! A GraphQL response is an HTTP `200` (under `application/json`), so it
4//! never passes through Autumn's problem-details filter, which is where
5//! server-side detail is normally redacted. This module is that filter for
6//! GraphQL:
7//!
8//! - [`IntoGraphqlError`] / [`GraphqlResultExt::gql`] turn an
9//!   [`AutumnError`] into a field error. Client errors (`4xx`) keep their
10//!   message; server errors (`5xx`) are logged and replaced by a generic
11//!   message. The HTTP status travels in `extensions.status` and a stable
12//!   machine-readable [`code`](codes) in `extensions.code`, so a client can
13//!   tell a `422` from a `503` without an HTTP status to read.
14//! - Validation failures carry their per-field messages in
15//!   `extensions.fields` as well as in the message.
16//! - Errors a resolver produced *without* a code (a bare `?` on some
17//!   library error, `ctx.data::<T>()?`, …) are "unexpected". When
18//!   `mask_unexpected_errors` is on, the plugin replaces their message
19//!   before the response leaves the process. Give an error a code to show
20//!   its message to clients.
21
22use std::collections::BTreeMap;
23
24use async_graphql::{ErrorExtensions, ServerError, Value};
25use autumn_web::AutumnError;
26use autumn_web::middleware::AutumnErrorInfo;
27use axum::response::IntoResponse;
28use http::StatusCode;
29
30/// Stable values for `errors[].extensions.code`.
31///
32/// Names follow the conventions of Apollo Server and GraphQL Yoga where one
33/// exists, so off-the-shelf clients recognise them.
34pub mod codes {
35    /// `400` — malformed input from the client.
36    pub const BAD_USER_INPUT: &str = "BAD_USER_INPUT";
37    /// `401` — no or invalid credentials.
38    pub const UNAUTHENTICATED: &str = "UNAUTHENTICATED";
39    /// `403` — authenticated but not allowed.
40    pub const FORBIDDEN: &str = "FORBIDDEN";
41    /// `404` — the addressed entity does not exist.
42    pub const NOT_FOUND: &str = "NOT_FOUND";
43    /// `409` — a conflicting concurrent change.
44    pub const CONFLICT: &str = "CONFLICT";
45    /// `422` — the model's validation rules rejected the input.
46    pub const VALIDATION_FAILED: &str = "VALIDATION_FAILED";
47    /// `429` — rate limited.
48    pub const RATE_LIMITED: &str = "RATE_LIMITED";
49    /// Any other `4xx`.
50    pub const CLIENT_ERROR: &str = "CLIENT_ERROR";
51    /// `503` — a dependency is unavailable; retrying later may succeed.
52    pub const SERVICE_UNAVAILABLE: &str = "SERVICE_UNAVAILABLE";
53    /// Any other `5xx`, and every masked unexpected error.
54    pub const INTERNAL_SERVER_ERROR: &str = "INTERNAL_SERVER_ERROR";
55    /// The document is not syntactically valid GraphQL.
56    pub const GRAPHQL_PARSE_FAILED: &str = "GRAPHQL_PARSE_FAILED";
57    /// The document does not validate against the schema.
58    pub const GRAPHQL_VALIDATION_FAILED: &str = "GRAPHQL_VALIDATION_FAILED";
59    /// The HTTP request itself is malformed (bad JSON, missing `query`, …).
60    pub const BAD_REQUEST: &str = "BAD_REQUEST";
61    /// The document exceeds a configured static-analysis limit
62    /// (`extensions.limit`, `extensions.max`, `extensions.actual` say which).
63    pub const QUERY_LIMIT_EXCEEDED: &str = "QUERY_LIMIT_EXCEEDED";
64    /// `__schema`/`__type` was selected while introspection is off.
65    pub const INTROSPECTION_DISABLED: &str = "INTROSPECTION_DISABLED";
66    /// The operation did not finish within `timeout_ms`.
67    pub const OPERATION_TIMEOUT: &str = "OPERATION_TIMEOUT";
68    /// A hash-only request named a document the server does not have.
69    pub const PERSISTED_QUERY_NOT_FOUND: &str = "PERSISTED_QUERY_NOT_FOUND";
70    /// A hash-only request reached a server with persisted queries off.
71    pub const PERSISTED_QUERY_NOT_SUPPORTED: &str = "PERSISTED_QUERY_NOT_SUPPORTED";
72    /// The document text does not hash to the hash the client sent.
73    pub const PERSISTED_QUERY_HASH_MISMATCH: &str = "PERSISTED_QUERY_HASH_MISMATCH";
74    /// Trusted-documents mode refused a document not in the manifest.
75    pub const OPERATION_NOT_ALLOWLISTED: &str = "OPERATION_NOT_ALLOWLISTED";
76    /// A mutation or subscription was sent over `GET`.
77    pub const METHOD_NOT_ALLOWED: &str = "METHOD_NOT_ALLOWED";
78    /// Batching is off, or the batch is larger than `max_operations`.
79    pub const BATCH_REJECTED: &str = "BATCH_REJECTED";
80    /// The request body is larger than allowed.
81    pub const PAYLOAD_TOO_LARGE: &str = "PAYLOAD_TOO_LARGE";
82    /// The request's `Content-Type` is not one this endpoint accepts.
83    pub const UNSUPPORTED_MEDIA_TYPE: &str = "UNSUPPORTED_MEDIA_TYPE";
84    /// The request's `Accept` header names no media type this endpoint serves.
85    pub const NOT_ACCEPTABLE: &str = "NOT_ACCEPTABLE";
86    /// A multipart request without a preflight-forcing header.
87    pub const CSRF_PREVENTION: &str = "CSRF_PREVENTION";
88    /// The endpoint is at its concurrent-connection limit.
89    pub const TOO_MANY_CONNECTIONS: &str = "TOO_MANY_CONNECTIONS";
90}
91
92/// The generic message a redacted or masked error carries.
93pub const INTERNAL_MESSAGE: &str = "Internal server error";
94
95/// The `extensions.code` for an HTTP status.
96#[must_use]
97pub fn code_for_status(status: StatusCode) -> &'static str {
98    match status.as_u16() {
99        400 => codes::BAD_USER_INPUT,
100        401 => codes::UNAUTHENTICATED,
101        403 => codes::FORBIDDEN,
102        404 => codes::NOT_FOUND,
103        409 => codes::CONFLICT,
104        422 => codes::VALIDATION_FAILED,
105        429 => codes::RATE_LIMITED,
106        503 => codes::SERVICE_UNAVAILABLE,
107        s if (400..500).contains(&s) => codes::CLIENT_ERROR,
108        _ => codes::INTERNAL_SERVER_ERROR,
109    }
110}
111
112/// Build a coded GraphQL error: `extensions.code` and, when given,
113/// `extensions.status`.
114///
115/// ```
116/// use autumn_plugin_graphql::error::{codes, graphql_error};
117///
118/// let err = graphql_error(codes::NOT_FOUND, "no such note", Some(404));
119/// assert_eq!(err.message, "no such note");
120/// ```
121#[must_use]
122pub fn graphql_error(
123    code: &str,
124    message: impl Into<String>,
125    status: Option<u16>,
126) -> async_graphql::Error {
127    let code = code.to_owned();
128    async_graphql::Error::new(message).extend_with(move |_, e| {
129        e.set("code", code.clone());
130        if let Some(status) = status {
131            e.set("status", status);
132        }
133    })
134}
135
136/// Conversion into a coded, redaction-safe GraphQL error.
137pub trait IntoGraphqlError {
138    /// Convert, logging and redacting server-side detail.
139    fn into_graphql_error(self) -> async_graphql::Error;
140}
141
142impl IntoGraphqlError for AutumnError {
143    fn into_graphql_error(self) -> async_graphql::Error {
144        // `into_response` is the one place core finalises an error: it applies
145        // its own status remapping (a cancelled statement becomes a 503) and
146        // stashes the status, message and field details as
147        // `AutumnErrorInfo`, which is readable without parsing the body.
148        let response = self.into_response();
149        let info = response.extensions().get::<AutumnErrorInfo>().cloned();
150        let (status, message, details) = info.map_or_else(
151            || (response.status(), String::new(), None),
152            |info| (info.status, info.message, info.details),
153        );
154        coded_error(status, &message, details.as_ref())
155    }
156}
157
158fn coded_error(
159    status: StatusCode,
160    message: &str,
161    details: Option<&std::collections::HashMap<String, Vec<String>>>,
162) -> async_graphql::Error {
163    let code = code_for_status(status);
164    if status.is_server_error() || !status.is_client_error() {
165        tracing::error!(status = %status, error = %message, "GraphQL resolver failed");
166        return graphql_error(code, INTERNAL_MESSAGE, Some(status.as_u16()));
167    }
168    let fields: Option<BTreeMap<&String, &Vec<String>>> = details
169        .filter(|d| !d.is_empty())
170        .map(|d| d.iter().collect());
171    let text = fields.as_ref().map_or_else(
172        || message.to_owned(),
173        |fields| {
174            // A validation error's own message is the summary "Validation
175            // failed"; the per-field reasons are what a client can act on, and
176            // `message` is the one channel every client displays. Sorted (the
177            // map is a BTreeMap) so the text is stable.
178            fields
179                .iter()
180                .flat_map(|(field, reasons)| reasons.iter().map(move |r| format!("{field}: {r}")))
181                .collect::<Vec<_>>()
182                .join("; ")
183        },
184    );
185    let field_map = fields.map(|fields| {
186        Value::Object(
187            fields
188                .into_iter()
189                .map(|(field, reasons)| {
190                    (
191                        async_graphql::Name::new(field),
192                        Value::List(reasons.iter().map(|r| Value::from(r.as_str())).collect()),
193                    )
194                })
195                .collect(),
196        )
197    });
198    graphql_error(code, text, Some(status.as_u16())).extend_with(move |_, e| {
199        if let Some(fields) = &field_map {
200            e.set("fields", fields.clone());
201        }
202    })
203}
204
205/// `?`-friendly conversion for resolver results.
206///
207/// ```ignore
208/// async fn note(&self, ctx: &Context<'_>, id: ID) -> async_graphql::Result<Option<Note>> {
209///     repo(ctx)?.find_by_id(parse_id(&id)?).await.gql()
210/// }
211/// ```
212pub trait GraphqlResultExt<T> {
213    /// Map the error side through [`IntoGraphqlError`].
214    ///
215    /// # Errors
216    ///
217    /// Returns the converted error when `self` is `Err`.
218    fn gql(self) -> async_graphql::Result<T>;
219}
220
221impl<T, E: IntoGraphqlError> GraphqlResultExt<T> for Result<T, E> {
222    fn gql(self) -> async_graphql::Result<T> {
223        self.map_err(IntoGraphqlError::into_graphql_error)
224    }
225}
226
227/// Read `extensions.code` off a server error, if any.
228#[must_use]
229pub fn error_code(error: &ServerError) -> Option<&str> {
230    match error.extensions.as_ref()?.get("code")? {
231        Value::String(code) => Some(code.as_str()),
232        _ => None,
233    }
234}
235
236/// Whether `error` was raised during execution (it has a response path)
237/// rather than while parsing or validating the document.
238#[must_use]
239pub const fn is_execution_error(error: &ServerError) -> bool {
240    !error.path.is_empty()
241}
242
243/// Build a pre-execution [`ServerError`] carrying `code` and `status`, plus
244/// any extra extension values.
245#[must_use]
246pub fn request_error(
247    code: &str,
248    message: impl Into<String>,
249    status: StatusCode,
250    extra: &[(&str, Value)],
251) -> ServerError {
252    let mut error = ServerError::new(message, None);
253    let mut extensions = async_graphql::ErrorExtensionValues::default();
254    extensions.set("code", code);
255    extensions.set("status", status.as_u16());
256    for (key, value) in extra {
257        extensions.set(key, value.clone());
258    }
259    error.extensions = Some(extensions);
260    error
261}
262
263/// Post-process a response's errors: give every error a code, and mask
264/// unexpected execution errors when `mask` is on.
265///
266/// `parsed` says whether the document parsed; it decides whether an
267/// uncoded pre-execution error is a parse or a validation failure.
268/// Returns the codes of every error, for metrics.
269pub fn finalize_errors(errors: &mut [ServerError], mask: bool, parsed: bool) -> Vec<String> {
270    let mut seen = Vec::with_capacity(errors.len());
271    for error in errors.iter_mut() {
272        if let Some(code) = error_code(error) {
273            seen.push(code.to_owned());
274            continue;
275        }
276        let code = if is_execution_error(error) {
277            if mask {
278                tracing::error!(
279                    error = %error.message,
280                    path = ?error.path,
281                    "masked an unexpected GraphQL resolver error (give it an extensions.code to \
282                     expose it)"
283                );
284                INTERNAL_MESSAGE.clone_into(&mut error.message);
285            }
286            codes::INTERNAL_SERVER_ERROR
287        } else if parsed {
288            codes::GRAPHQL_VALIDATION_FAILED
289        } else {
290            codes::GRAPHQL_PARSE_FAILED
291        };
292        error
293            .extensions
294            .get_or_insert_with(Default::default)
295            .set("code", code);
296        seen.push(code.to_owned());
297    }
298    seen
299}
300
301#[cfg(test)]
302#[allow(clippy::unwrap_used)]
303mod tests {
304    use std::collections::HashMap;
305
306    use async_graphql::PathSegment;
307
308    use super::*;
309
310    fn ext(err: &async_graphql::Error, key: &str) -> Option<Value> {
311        err.extensions.as_ref()?.get(key).cloned()
312    }
313
314    #[test]
315    fn client_errors_keep_their_message_and_carry_code_and_status() {
316        let err = AutumnError::not_found_msg("note 9 not found").into_graphql_error();
317        assert_eq!(err.message, "note 9 not found");
318        assert_eq!(ext(&err, "code"), Some(Value::from("NOT_FOUND")));
319        assert_eq!(ext(&err, "status"), Some(Value::from(404)));
320
321        let err = AutumnError::unauthorized_msg("sign in").into_graphql_error();
322        assert_eq!(ext(&err, "code"), Some(Value::from("UNAUTHENTICATED")));
323        let err = AutumnError::forbidden_msg("no").into_graphql_error();
324        assert_eq!(ext(&err, "code"), Some(Value::from("FORBIDDEN")));
325        let err = AutumnError::bad_request_msg("bad id").into_graphql_error();
326        assert_eq!(ext(&err, "code"), Some(Value::from("BAD_USER_INPUT")));
327    }
328
329    #[test]
330    fn server_errors_are_redacted() {
331        let err = AutumnError::internal_server_error_msg("connection refused: 10.0.0.3:5432")
332            .into_graphql_error();
333        assert_eq!(err.message, INTERNAL_MESSAGE);
334        assert_eq!(
335            ext(&err, "code"),
336            Some(Value::from("INTERNAL_SERVER_ERROR"))
337        );
338        assert_eq!(ext(&err, "status"), Some(Value::from(500)));
339
340        let err = AutumnError::service_unavailable_msg("no pool").into_graphql_error();
341        assert_eq!(err.message, INTERNAL_MESSAGE);
342        assert_eq!(ext(&err, "code"), Some(Value::from("SERVICE_UNAVAILABLE")));
343        assert_eq!(ext(&err, "status"), Some(Value::from(503)));
344    }
345
346    #[test]
347    fn validation_details_become_message_and_fields() {
348        let mut details = HashMap::new();
349        details.insert("title".to_owned(), vec!["too short".to_owned()]);
350        details.insert(
351            "body".to_owned(),
352            vec!["required".to_owned(), "x".to_owned()],
353        );
354        let err = AutumnError::validation(details).into_graphql_error();
355        assert_eq!(err.message, "body: required; body: x; title: too short");
356        assert_eq!(ext(&err, "code"), Some(Value::from("VALIDATION_FAILED")));
357        assert_eq!(ext(&err, "status"), Some(Value::from(422)));
358        let Some(Value::Object(fields)) = ext(&err, "fields") else {
359            panic!("fields missing");
360        };
361        assert_eq!(
362            fields.get("title"),
363            Some(&Value::List(vec![Value::from("too short")]))
364        );
365    }
366
367    #[test]
368    fn gql_maps_results() {
369        let ok: Result<u8, AutumnError> = Ok(1);
370        assert_eq!(ok.gql().unwrap(), 1);
371        let err: Result<u8, AutumnError> = Err(AutumnError::conflict_msg("stale"));
372        let err = err.gql().unwrap_err();
373        assert_eq!(ext(&err, "code"), Some(Value::from("CONFLICT")));
374    }
375
376    #[test]
377    fn status_codes_map_to_stable_codes() {
378        assert_eq!(
379            code_for_status(StatusCode::TOO_MANY_REQUESTS),
380            codes::RATE_LIMITED
381        );
382        assert_eq!(code_for_status(StatusCode::GONE), codes::CLIENT_ERROR);
383        assert_eq!(
384            code_for_status(StatusCode::BAD_GATEWAY),
385            codes::INTERNAL_SERVER_ERROR
386        );
387        assert_eq!(
388            code_for_status(StatusCode::OK),
389            codes::INTERNAL_SERVER_ERROR
390        );
391    }
392
393    #[test]
394    fn finalize_masks_only_uncoded_execution_errors() {
395        let mut unexpected = ServerError::new("Data `Pool` does not exist.", None);
396        unexpected.path = vec![PathSegment::Field("notes".into())];
397        let mut coded = ServerError::new("note 2 is pinned", None);
398        coded.path = vec![PathSegment::Field("deleteNote".into())];
399        let mut ext = async_graphql::ErrorExtensionValues::default();
400        ext.set("code", "VALIDATION_FAILED");
401        coded.extensions = Some(ext);
402        let validation = ServerError::new("Unknown field \"nope\"", None);
403
404        let mut errors = vec![unexpected, coded, validation];
405        let seen = finalize_errors(&mut errors, true, true);
406        assert_eq!(errors[0].message, INTERNAL_MESSAGE);
407        assert_eq!(errors[1].message, "note 2 is pinned");
408        assert_eq!(errors[2].message, "Unknown field \"nope\"");
409        assert_eq!(
410            seen,
411            [
412                "INTERNAL_SERVER_ERROR",
413                "VALIDATION_FAILED",
414                "GRAPHQL_VALIDATION_FAILED"
415            ]
416        );
417
418        let mut unmasked = vec![ServerError::new("boom", None)];
419        unmasked[0].path = vec![PathSegment::Index(0)];
420        finalize_errors(&mut unmasked, false, true);
421        assert_eq!(unmasked[0].message, "boom");
422        assert_eq!(error_code(&unmasked[0]), Some("INTERNAL_SERVER_ERROR"));
423
424        let mut parse = vec![ServerError::new(" --> 1:1 expected selection", None)];
425        finalize_errors(&mut parse, true, false);
426        assert_eq!(error_code(&parse[0]), Some("GRAPHQL_PARSE_FAILED"));
427    }
428
429    #[test]
430    fn request_errors_carry_extras() {
431        let err = request_error(
432            codes::QUERY_LIMIT_EXCEEDED,
433            "too deep",
434            StatusCode::BAD_REQUEST,
435            &[("limit", Value::from("depth"))],
436        );
437        assert_eq!(error_code(&err), Some("QUERY_LIMIT_EXCEEDED"));
438        assert_eq!(
439            err.extensions.as_ref().unwrap().get("limit"),
440            Some(&Value::from("depth"))
441        );
442        assert!(!is_execution_error(&err));
443    }
444}