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.

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 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:

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 objects 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 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:

  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
    }
  }
}

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.

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 introspectionrate 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:

💡Interested in learning more about GraphQL? Check out the articles below: