DEV Community

Cover image for GraphQL errors: the Good, the Bad and the Ugly
Gautier for Escape

Posted on • Originally published at escape.tech

GraphQL errors: the Good, the Bad and the Ugly

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!
}
Enter fullscreen mode Exit fullscreen mode

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"
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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."
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Some errors can be returned for both queries and mutations, others are exclusive to this specific mutation.
  2. 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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

An email/password form with a "Password too short" error above the form

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!
}
Enter fullscreen mode Exit fullscreen mode

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 }
  }
}
Enter fullscreen mode Exit fullscreen mode
{
  "data": {
    "register": {
      "__typename": "ValidationError",
      // Error messages specific to each input:
      "fieldErrors": [
        {"path": "email", "message": "This account already exists."},
        {"path": "password", "message": "Password too short."},
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The same email/password form as before, but both fields have an error: "this account already exists" and "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 } } });
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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:


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)

Collapse
 
boredcity profile image
boredcity • Edited

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!

Collapse
 
gauben profile image
Gautier • Edited

Thanks for your comment!

but won't you have to do the same for the Users / Messages / whatever else anyway?

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.

structured error approach still won't handle multiple errors of different kinds

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:

builder.mutationField('register', (t) => t.field({
  type: UserType,
  errors: { types: [ValidationError, ProfessionalEmailRequired] },
  args: {
    // Validation is purely static and declarative:
    email: t.arg.string({ validate: { email: true, maxLength: 255 }}),
    password: t.arg.string({ validate: { minLenght: 8, maxLength: 255 }})
  },
  resolve: async (_, { email, password }) => {
    // Looking for banned email providers requires a database
    // lookup and happens in the resolver:
    if (await isEmailBanned(email)) {
      throw new ProfessionalEmailRequired(/* ... */);
    }
    // ...
  }
}))
Enter fullscreen mode Exit fullscreen mode

Therefore, we might send several ValidationErrors at once (that's why it contains an array of fieldErrors) before even entering the resolver.

Also if the backend adds a new Error type, it's more changes to the schema and both client- and server-side.

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:

  • You have explicit breaking changes (because changes in schema can be tracked and versioned)
  • You gain type checking on the new error you create

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:

interface Error {
  message: String!
}

type NewBusinessError implements Error {
  # User-friendly error message for debug
  message: String!
  # Structured data for machine use
  foo: String!
  bar: [Float!]!
}
Enter fullscreen mode Exit fullscreen mode

Then when you query the mutation, the request looks like this:

mutation {
  register(email: $email, password: $password) {
    __typename
    ... on Success { data { id } }
    # The client may not know about `NewBusinessError`
    # but can still fetch a basic error message for debug
    ... on Error { message } 
  }
}
Enter fullscreen mode Exit fullscreen mode

but at some point it just won't scale

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

Anyway, it is a really interesting article even if I don't agree with it completely, thank you!

Thank you very much for your kind comment! Have a nice weekend.

Collapse
 
rcls profile image
OssiDev

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.