The Definitive Guide to Handling GraphQL Errors

Matt Krick
ITNEXT
Published in
7 min readMar 12, 2018

--

Get it? Cuz they’re errors... and you throw & catch ‘em …nevermind

Click here to share this article on LinkedIn »

Recently, I screwed up and it resulted in a client getting a white screen when they used our app. Like most apps, we have an initial GraphQL query that fetches a ton, including a list of all your notifications. One of those notifications referenced a field that no longer existed in the database (oops!). The result? GraphQL was a champ and sent both data and errors to the client. But the client, well it completely ignored the data because it handled the response as an error. In hindsight, that was pretty dumb. It’d be like flunking a student for getting less than 100%. It just ain’t right.

GraphQL’s ability to send both data and errors is nothing short of amazing. It’s like having a talk with a real human: “Hey Matt, here are those results you wanted. I got you everything except that task field; I went to look it up, but it didn’t exist in your database.” With all this power, we could do some really cool things on the client! Unfortunately, most client code boils down to this:

if (result.errors) throw result.errors[0]

That’s not perfect, but if we didn’t throw an error, then the onError handler wouldn’t be called, which is how I propagated server validation errors to the UI. So, choosing between writing a flawless server and not receiving server errors, I went with the former — and it worked for almost 2 years! …until it didn’t.

Identifying Error Types

To make sure I fixed the root cause, I started researching all the types of errors we throw in our app and all the ways other folks handle GraphQL errors. There are a plethora of errors that a client can encounter when querying a GraphQL Server. Whether it’s a query, mutation, or subscription, they all fall into 6 types:

  1. Server problems (5xx HTTP codes, 1xxx WebSocket codes)
  2. Client problems e.g. rate-limited, unauthorized, etc. (4xx HTTP codes)
  3. The query is missing/malformed
  4. The query fails GraphQL internal validation (syntax, schema logic, etc.)
  5. The user-supplied variables or context is bad and the resolve/subscribe function intentionally throws an error (e.g. not allowed to view requested user)
  6. An uncaught developer error occurred inside the resolve/subscribe function (e.g. poorly written database query)

So, which of these errors are critical enough to ignore all the data? Numbers 1–3 for sure, since they occur before GraphQL even get called. Number 4, too, it calls GraphQL, but only receives errors in response. For 5–6, GraphQL responds with both partial data and an array of errors. Some would conflate type 5 with type 2, for example running out of query “points” (like what GitHub does) could constitute an HTTP 429 (too many requests). But at the end of the day, the simplest answer is the best: If GraphQL gives you a result with data, even if that result contains errors, it is not an error. No changing HTTP codes based on error types, no reading the errors to decide how “critical” a particular error is, and no reading the data to see if it’s usable. I don’t care if the result is {data: {foo: null}}. Data is data; any arbitrary nully logic implemented after GraphQL returns is just that: arbitrary.

Following this logic, error types 1–4 would be sent as errors to the client because there is no result.data. But what about types 5–6?

Don’t Intentionally Throw Errors in GraphQL

As of March 2018, neither Apollo-Client (including subscriptions-transport-ws) nor Relay Modern is perfect at handling errors. Relay’s mutation API comes close with its onCompleted(result, errors) callback, but this is sorely missed for queries and subscriptions. Apollo is extra flexible with its ErrorPolicy; but neither offers best practices, so I propose my own: If the viewer should see the error, include the error as a field in the response payload. For example, if someone uses an expired invitation token and you want to tell them the token expired, your server shouldn’t throw an error during resolution. It should return its normal payload that includes the error field. It can be as simple as a string or as complicated as you desire:

return {
error: {
id: '123',
type: 'expiredToken',
subType: 'expiredInvitationToken',
message: 'The invitation has expired, please request a new one',
title: 'Expired invitation',
helpText: 'https://yoursite.co/expired-invitation-token',
language: 'en-US'
}
}

By including errors in your schema, life gets a lot easier:

  • All errors are sanitized and ready for the viewer before they hit the client
  • You don’t need to throw a stringified object and parse it on the client
  • You don’t have to send the same error in 22 different languages (you know who you are)
  • You can send the same error as a breadcrumb to your error logging service
  • Most importantly, your GraphQL errors array won’t include any user-facing errors which means your UI won’t ignore them!

For mutations (and subscriptions), that’s an easy sell. Even easier if you follow my hybrid approach to subscriptions because your subscriptions reuse your mutation payloads. But what about queries? There exists a dichotomy in GraphQL best practices today: mutations and subscriptions return a payload full of types, but a query just returns a type. Using my blunder as an example, imagine a request where team succeeds but notifications fails:

mainQuery {
team { #succeeds
name
}
notifications { #fails
text
}
}

To avoid losing partial data, we treat the whole thing as a success, but in doing so, we lose the errors! How can we get both? We can’t go back to throwing errors for the reasons listed above, but wrapping every object is a payload would be pretty ugly:

mainQuery {
teamPayload {
error {
message
}
team {
name
}
}
notificationPayload {
error {
message
}
notifications {
text
}
}
}

While it’s not ideal, this would only apply when the UI needs to know about an error. Sound familiar? It functions just like an error boundary in React:

The granularity of error boundaries is up to you. You may wrap top-level route components to display a “Something went wrong” message to the user, just like server-side frameworks often handle crashes. You may also wrap individual widgets in an error boundary to protect them from crashing the rest of the application.

So if returning a null or empty array suffices, go right on ahead; but send the event to your exception manager to track it. If you notice a particular query piece is failing regularly, then you can wrap it using a payload to create a pseudo error boundary. While more art than science, this means I treat all GraphQL operations the same, and I don’t needlessly bloat my entire schema.

Now when it comes to trusting the client, if the client shouldn’t see it, your server shouldn’t send it, which brings us to the final handler.

How to Hide Your Shortcomings (from the client)

Remember the good old days when all errors were unintentional? Nowadays playing catch with errors is more common than, well, actually playing catch (looking at you React v17 with your crazy promise throwing internals).

After refactoring our intentionally thrown errors into a regular field in our response payload, any remaining errors must be unintentional (i.e. developer errors), which means we should cover our tracks and replace the message with something vague like: “Server Error”. In a perfect world, these would be caught, sanitized, and returned as an error property in the response, but you’ll never catch ’em all (so you can stop wrapping every single statement in a try/catch). We still send the real error to our logging service so we can fix it before anyone knows its broken, but the client should never see it because the error might include sensitive things like our actual database queries that we use in production. Along with the vague message, it is worthwhile to keep the error’spath, since that will help us determine where the error occurred. Again, simple is best: For every error in errors, send a generic message and path to the client alongside the partial data.

Once that result is on the client, it will be handled as a successful request. You could even ignore the errors and be fine (and if it’s a query, you might have to!). However, if you wanted to make use of it, you could still reference it anywhere the errors array is available. Putting it all together, here’s how it looks in Relay Modern:

// Called for error types 1-4 (5xx, 4xx, missing/invalid query)
const
onError = (err) => {
this.setState({err})
}

const onCompleted = (result, errs) => {
// Called for error type 6 (eg unexpected missing DB field)
const
err = errs.find(({path}) => path.includes('approve'));
if (err) {
onError(err.message);
}

// called for error type 5 (eg expired auth token)
const
{approve: {error: {message}}} = result;
onError(message);
}

commitMutation(env, {mutation, onCompleted, onError})

Remember, this works perfectly for mutations, but queries and subscriptions swallow errors unless they’re thrown, which means if you want it in your UI, you better put it in your schema!

Conclusion

tl;dr

  • If GraphQL gives you results.data, it is not an error, so don’t throw it on the client.
  • If the viewer should see the error, return the error as a field in the response payload. If it’s a query, make a response payload.
  • Replace any remaining GraphQL errors with a generic message, but don’t throw it on the client and don’t expect the UI to always be able to handle it.

Whether it’s for a query, mutation, or subscription, we identified 6 distinct types of errors that a request can encounter when returning a GraphQL response. We came up with a strategy to guarantee that partial data is never ignored by the client. Finally, we ensured that the viewer always sees the errors we want them to see (and nothing more!). We also managed to avoid the deep, dark rabbit hole of throwing custom errors like GraphQLConnectionError that seem so popular despite their shortcomings. How do you handle errors? Is this already common knowledge and I’m just late to the party? Let me know.

--

--