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 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:
type Mutation {
register(email: String!, password: String!): User!
}
type User {
id: ID!
email: String!
}
The
register
mutation takes an email and a password and returns the newly created user.
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 object types from the same resolver. Let's refactor our resolver a bit to include an error type:
type Mutation {
register(email: String!, password: String!): RegisterResult!
}
union RegisterResult = User | Error
type User {
id: ID!
email: String!
}
type Error {
message: String!
}
register
may now return a user or an error.
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 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 error should be next to the password field, but the frontend has no way to know where to place it.
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."},
]
}
}
}
The frontend is now able to show several contextualized errors at the same time.
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.
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
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!
Top comments (3)
I think I see your point, but, honestly, I'm still not convinced that errors array is worse than structured errors.
Yes, you have to parse an array of objects, but won't you have to do the same for the Users / Messages / whatever else anyway? 🤔 How come errors should be handled differently?
Also, structured error approach still won' handle multiple errors of different kinds: you can't have
"__typename": "ValidationError and ProfessionalEmailRequired"
which is trivial to handle if you receive an array.Also if the backend adds a new Error type, it's more changes to the schema and both client- and server-side.
So, yeah, I can see the appeal of having simplified (well, kinda-sorta) API, but at some point it just won't scale, IMO. There's a reason we use arrays to store lists of things: that's literally what they're made for.
Anyway, it is a really interesting article even if I don't agree with it completely, thank you!
Thanks for your comment!
You're a 100% right, you'll have to loop over
ValidationError.fieldErrors
too. The main advantage of using GraphQL error objects over raw JSON objects is being able to type the response object and adding fields in non-breaking changes. That's mostly the same reasoning between using GraphQL over a raw/untyped exchange protocol.The article tries to be as abstract as possible, but to answer this I'll need to add a bit of context: ValidationError and ProfessionalEmailRequired cannot be emitted at the same time. ValidationErrors are thrown thanks to a purely static and declarative mechanism using pothos and zod, before any business logic happens.
Our
register
mutation looks roughly like this:Therefore, we might send several ValidationErrors at once (that's why it contains an array of
fieldErrors
) before even entering the resolver.True, but emitting and handling new errors requires changes in both the backend and the frontend anyway. Having the schema reflecting these changes help in two ways:
Pothos gives precious tips about it: you should have all errors extend an
Error
interface, allowing adding new errors in a non-breaking way. It looks like this:Then when you query the mutation, the request looks like this:
Speaking of non-scaling things, we have an unfortunately rather extensive experience. That's one of the reasons behind this article: we know what doesn't scale.
We however still don't know what scales
Thank you very much for your kind comment! Have a nice weekend.
Using HTTP error codes can really mess up some client libraries. I'm not sure of the current state, but a year or two ago Apollo was pretty bad at handling anything except 200 OK and threw an exception as opposed to actually returning the result. So, if you use a library like that make sure it actually handles those status codes properly.