GraphQL errors: the Good, the Bad and the Ugly
Managing GraphQL errors can be quite a challenging task, and we tried a lot of different approaches over time. Keep reading to know what we've learned along the way.
We, at Escape, have been using GraphQL for our apps for a long time, before many quality tutorials were available. Because we lacked experience, we made design mistakes on many aspects of our GraphQL API. This article reviews the evolution of how we return GraphQL errors from our API, for consumption by the frontend and other internal services, emphasizing what could be improved on each step.
To illustrate this article, we will take the example of an account creation mutation:
Because the user creation might fail, we need to tell our API consumer (for instance the frontend) that something went wrong and what exactly went wrong. There are many ways to do it, and some work better than others.
The Ugly: the errors
field
A GraphQL response is a JSON object with up to two keys: data
and errors
. We implemented error responses leveraging the latter. For instance, considering our register
mutation from before, several errors could be raised:
- The email already exists
- The email uses a banned email provider
- The password is too short
- Unexpected runtime errors (e.g. a database connection failed)
The specification indicates that errors
is an array of objects with a message
field but allows additional details in an extensions
field. We used extensions.code
to convey a machine-interpretable response:
{
"errors": [
{
// User-friendly error message
"message": "Please provide a professional email address.",
"extensions": {
// Machine-friendly error code
"code": "PROFESSIONAL_EMAIL_REQUIRED"
}
}
]
}
Several problems emerge from this approach. The most annoying one is that errors
is an array: the consumer has to iterate over it to collect the errors. What to do if the array contains several errors but none that can be handled by our frontend? This led to hard to maintain switch
inside for
loops, with fallback cases afterwards.
On the plus side, all of our errors are returned using the same mechanism, leading to a simpler implementation on the backend, but we have a lot of room for improvement.
The Bad: an Error
type
GraphQL has a neat type system with a feature named Union types. It allows returning several objects types from the same resolver. Let's refactor our resolver a bit to include an error type:
Our type definition is getting significantly more complicated, but that's for good! The registration query would now look like this:
mutation {
register(email: "gautier@example.com", password: "p4ssw0rd") {
__typename
# Query different fields depending on the response type
...on User {
id
}
...on Error {
message
}
}
}
In case the user provides a password that is too short, the response payload would look like this:
{
"data": {
"register": {
// This allows the consumer to know which fields are available
"__typename": "Error",
"message": "Password too short."
}
}
}
This approach requires us to classify GraphQL errors in two categories: the ones we want in the response errors
field, and the ones we return as an Error
type. There are two concepts to account for when making this distinction:
- Some errors can be returned for both queries and mutations, others are exclusive to this specific mutation.
- Some errors are actionable, some are not.
We consider errors that can be returned for queries too because we want to keep queries short:
# This is short and neat
query {
# User "1" might not exist but let's ignore it for now
user(id: 1) {
posts {
title # ✨
}
}
}
# This is bloated and cumbersome
query {
user(id: 1) {
__typename
...on User {
posts {
# Consider possible errors on all fields, even the nested ones
__typename
...on Post {
title # 😫
}
...on Error {
message
}
}
}
...on Error {
message
}
}
}
Therefore, errors such as User not found, Unauthorized or runtime errors should still be returned in the errors
response field, to have query and mutation responses handled the same way.
Furthermore, actionable errors are part of the normal execution flow. It makes sense for a registration to fail and having an error response type associated to it.
Let's take our list of errors from above and categorize them:
- The email already exists: specific to this resolver and actionable →
Error
type - The email uses a banned email provider: same →
Error
type - The password is too short: same →
Error
type - Unexpected runtime errors: can happen for both queries and mutations and hardly actionable for frontend users →
errors
field
These categories are quite simple to use in the frontend: if __typename
is Error
, display the error above the form, otherwise, show a generic “Something went wrong, please try again” error.
This solution is however less flexible than the previous one: we can only send one actionable error at a time, even when multiple errors could be returned at the same time. We might also want to be able to place the error message right next to its related form input.
The Good: structured errors
The API might return different kinds of errors, with specific data attached. Let's create structured errors for our actionable errors from before. The goal is to replace the generic Error
type with more specific domain errors.
type Mutation {
register(email: String!, password: String!): RegisterResult!
}
# Use different types for all possible actionable errors
union RegisterResult = User | ValidationError | ProfessionalEmailRequired
type User {
id: ID!
email: String!
}
# Malformed inputs
type ValidationError {
# Allow several errors at the same time!
fieldErrors: [FieldError!]!
}
type FieldError {
path: String!
message: String!
}
# Business specific errors (e.g. banned email providers)
type ProfessionalEmailRequired {
provider: String!
}
When creating our error types, we took into consideration that some errors might be multiple: for instance, the validation step might throw several errors at the same time (email already used, password too short etc.). That is why the error contains an array of fieldErrors
.
By requesting all the possible errors, it is now possible for the frontend to display contextualized errors (i.e. errors next to their input):
mutation {
register(email: "gautier@example.com", password: "p4ssw0rd") {
__typename
# Query different fields depending on the response type
...on User { id }
...on ValidationError {
fieldErrors { path message }
}
...on ProfessionalEmailRequired { provider }
}
}
{
"data": {
"register": {
"__typename": "ValidationError",
// Error messages specific to each input:
"fieldErrors": [
{"path": "email", "message": "This account already exists."},
{"path": "password", "message": "Password too short."},
]
}
}
}
This architecture will also make some future considerations easier to implement, especially internationalization (i18n).
We are currently refactoring our API to use structured errors. It represents a substantial amount of work, but we are now able to display more precise error messages, especially for complex inputs and flows. Structured errors help us improve the user experience of our products.
HTTP errors
We spent a while talking about error types, but let's get back to the errors
field to conclude. Sending errors in here allows keeping queries short and clear, and we still use it for Not found and Unauthorized errors. With a twist!
The GraphQL over HTTP specification states the following:
The server SHOULD use the 200
status code, independent of any GraphQL request error or GraphQL field error raised.
We decided to ignore this recommendation and attach semantic HTTP error codes to queries with errors. Yoga's error masking makes it really simple to transform JS error objects into GraphQL errors with the right HTTP code attached:
const yoga = createYoga({
schema,
maskedErrors: {
maskError(error, message) {
const cause = (error as GraphQLError).originalError;
// Transform JS error objects into GraphQL errors
if (cause instanceof UnauthorizedError)
return new GraphQLError(cause.message, { extensions: { http: { status: 401 } } });
if (cause instanceof NotFoundError)
return new GraphQLError(cause.message, { extensions: { http: { status: 404 } } });
// Default to 500 with a generic message
return new GraphQLError(message, { extensions: { http: { status: 500 } } });
},
},
});
This enables the frontend to show the correct HTTP error page in case of a GraphQL error without even parsing the response.
How to make sure my GraphQL endpoint handles errors properly?
Since GraphQL is new and lacks just lacked the proper tooling, many development teams are just skipping security... In practice, how can you ensure that your development team handles errors properly over time and while your codebase evolves?
From familiar patterns (authorization/authentication) to new considerations (disabling introspection, rate limiting, etc.), there’s a lot to keep in mind regarding GraphQL security. That’s why at Escape, we created a scanner that specializes in GraphQL APIs. It can streamline this process by quickly identifying issues across your API endpoints and providing remediation snippets—all in as little as 15 minutes, without complex integrations or traffic monitoring.
We constantly update our engine with state-of-the-art GraphQL Security research so that you never have to worry about the security of your GraphQL application again!
Closing words
This is the end of this we-do-it-that-way article, we hope you enjoyed this format that allows to peep inside our development practices at Escape. We are continuously discovering new ways to design GraphQL APIs and we will keep writing about the different steps we took until reaching the state of the art. Please share your thoughts where you found this article, we have a lot to learn from your experiences!
Read more
We are not the first ones to write about returning GraphQL errors, you might be interested in these articles/documentations too:
- 200 OK! Error Handling in GraphQL | by Sasha Solomon
- GraphQL error handling to the max with Typescript, codegen and fp-ts – The Guild
- Errors plugin for Pothos GraphQL
💡Interested in learning more about GraphQL? Check out the articles below: