The Definitive Guide to Handling GraphQL Errors

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:
- Server problems (5xx HTTP codes, 1xxx WebSocket codes)
- Client problems e.g. rate-limited, unauthorized, etc. (4xx HTTP codes)
- The
query
is missing/malformed - The
query
fails GraphQL internal validation (syntax, schema logic, etc.) - The user-supplied
variables
orcontext
is bad and theresolve
/subscribe
function intentionally throws an error (e.g. not allowed to view requested user) - 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.