Introduction
Let me start with my golden principles of error handling:
- If anything goes wrong, the user should be notified. No exceptions (pun intended).
- We should fall into The Pit of Success. That is, error handling should just work.
- We should be able to customize global error handling up to downright disabling it and implementing something completely custom.
Throughout the article, we will apply these principles to handling Apollo errors (both GraphQL and network) in React. We assume the use of Apollo Server and Typescript. SSR is also covered.
What we want
The way I see things, the best way to adhere to these principles is to implement the following:
- A notification system that allows showing all kinds of messages, including errors.
- A global error handler that shows error messages via the notification system. It can also log errors, send them to your error tracker, redirect to the login page in case of an authentication error, etc.
- A way for us to communicate with the global error handler in order to alter its behavior (e.g. prevent it from showing an error message).
How to pass GraphQL errors to the client
A quick recap of what GraphQL errors look like (shamelessly borrowed from GraphQL Spec):
{
"errors": [
{
"message": "Name for character with ID 1002 could not be fetched.",
"locations": [{ "line": 6, "column": 7 }],
"path": ["hero", "heroFriends", 1, "name"],
"extensions": {
"code": "CAN_NOT_FETCH_BY_ID",
"timestamp": "Fri Feb 9 14:33:09 UTC 2018"
}
}
]
}
- There can be multiple
errors
. - The
message
field is a description of the error intended for the developer (please don't show it to the user). - The
locations
andpath
fields point to the associated points in the request and the response field respectively. That is, errors are detached from where they occurred, and we need to implement some custom logic to parse thepath
field if we want to know what field caused the error. Since thenull
result will bubble up to the nearest nullable field, it may be tricky. - The
extensions
field is a map of arbitrary data associated with the error. We specifically care about itscode
field which is set by Apollo Server (see built-in error codes). It should be used to differentiate errors on the client side. - Errors are not a part of the GraphQL Schema. Standard fields like
message
,locations
,path
, and (Apollo-specific)extensions.code
can (somewhat) be typed, but the rest of theextensions
field is a wild west.
Consider treating errors as data
There is an approach to handling errors where they are added to the schema (by using unions and interfaces or by providing an additional field). It basically says "Let's add errors to GraphQL Schema to have typed errors that are co-located to where they occurred".
This approach is great, but not always viable. Some errors can happen anywhere, and there is no point in polluting your schema with them. One such example is UNAUTHENTICATED
error in a private application. And sure enough, there is always a possibility of an unexpected error.
That is, with this approach, we have both actual errors and errors in data to handle. For the latter, we need to constantly repeat the error handling logic (which doesn't really conform to the "Pit of Success" principle) or create a custom link to handle them, because Apollo doesn’t know that they are, in fact, errors. In contrast, there is a built-in onError
link for handling actual errors.
Personally, I think that it's best to use actual errors by default. We should switch to "errors as data" only in specific cases when we do need to know anything else other than code
. We likely don’t want to use global error handling for them anyway, and there's a good chance that we can avoid them altogether.
Use null instead of NOT_FOUND in queries
There is a long-standing issue with error caching (its absence, that is), which leads to errors being rendered as a loading state on the server side. Therefore, if you're doing SSR, you might want to design your schema in such a way that there are no intentional errors in queries whatsoever. To do so, we can use null
values instead of NOT_FOUND
errors (see how we essentially treated errors as data here?). Note how nothing stops us from setting the response code to 404
in case of a null
value, should we want so.
In fact, even if you don't do SSR, you might still benefit from discarding the NOT_FOUND
errors in queries. There is no previousData
analog for errors, so if you want to render a "not found" page state, it's easier to rely on data
/previousData
with a value of null
(and, honestly, the absence of something in case of a read operation feels more data-like than error-like to me).
Most likely, all other errors can be treated as unintentional and related to the whole query (that is, we don't need to parse the path
field because we just don't care). There's either a bug in our code (e.g. INTERNAL_SERVER_ERROR
) or the user is doing something malicious (e.g. FORBIDDEN
). Either way, it's more or less fine to SSR them as a loading state with an appropriate response code and then show a notification (or something custom) on the client side. It's not the most elegant solution, but it's the best we can do given the circumstances.
There is also a special error: UNAUTHENTICATED
. Fortunately, we most likely don't need to actually render it, because it should be rendered on the server side as a redirect.
Rely on client-side validation only
Validation should be completely static. That is, we should be able to validate the user input beforehand. Therefore, any validation errors should be treated as bugs in the frontend validation logic.
It means that validation errors can and should provide extensions
with detailed validation results, but we shouldn't base our logic on them. We should only apply the global handler to such errors, anticipating that these errors should never happen.
It’s worth noting that updating validation rules is technically a breaking change, so it’s perfectly fine to update client-side code in response to these changes.
Make error codes specific
In order to avoid non-standard extensions
, we can make error code
s more specific. For instance, don't use the NOT_FOUND
code. Make it USER_NOT_FOUND
, JOB_NOT_FOUND
, MEANING_NOT_FOUND
, and so on.
Specific error codes also aid in determining the origin of the error without parsing the path
field. Especially for mutations, which are usually much more granular than queries.
For the sake of clarity: unlike queries, it's perfectly normal to use SOMETHING_NOT_FOUND
errors in mutations.
Provide enum with error codes
In order to type extensions.code
, we need to provide an enum with possible error codes. The easiest way to do so is to add it to the GraphQL Schema. Tools like GraphQL Code Generator can then generate the actual type that we can use.
Sadly, there is no guarantee that the enum will ever be complete. There should always be a fallback to an unknown error.
There is also no way to tell what errors the given operation may produce. We can only document them using comments.
Use standard errors
Use the built-in error codes where appropriate. Use ApolloError
to create custom errors. Use standard error code format. Be consistent. Don't reinvent the wheel.
The notification system
The notification system is highly application-dependent. We can create something custom or use a library like notistack. For the sake of simplicity, we’ll assume the use of notistack from now on, but the actual implementation doesn’t really matter.
With notistack, we wrap the whole application in its provider:
import { SnackbarProvider } from "notistack";
import { RestOfTheApp } from "./RestOfTheApp";
const App = () => (
<SnackbarProvider>
<RestOfTheApp />
</SnackbarProvider>
);
And then queue notifications like this:
import { useSnackbar } from "notistack";
const RestOfTheApp = () => {
const { enqueueSnackbar } = useSnackbar();
const handleClick = () => {
enqueueSnackbar("Button clicked!");
};
return <button onClick={handleClick}>Show snackbar</button>;
};
As you can see, notistack can only be accessed from within React. Let’s see how we can connect an Apollo client instance to it.
Creating Apollo client instance within React
The obvious approach is to create an Apollo client instance within React:
import { useSnackbar } from "notistack";
import { ApolloProvider } from "@apollo/client";
import { useMemoOne } from "use-memo-one";
import { createApolloClient } from "./createApolloClient";
import { ActualApp } from "./ActualApp";
const RestOfTheApp = () => {
const { enqueueSnackbar } = useSnackbar();
const client = useMemoOne(() => createApolloClient({ enqueueSnackbar }), [enqueueSnackbar]);
return (
<ApolloProvider client={client}>
<ActualApp />
</ApolloProvider>
);
};
We create an Apollo client instance with our own createApolloClient
function and memoize it with useMemoOne
, ensuring a semantic guarantee of memoization.
This seems suboptimal, because:
- This will break if
enqueueSnackbar
changes because we will create a new Apollo client instance. This can be fixed with our ownstableEnqueueSnackbar
with a stable identity, which can call the latestenqueueSnackbar
under the hood, but it will make the code even more complex. And ifRestOfTheApp
itself remounts for some reason, the client instance will be recreated anyway. - In the case of SSR, we can’t create an Apollo client instance within React, because the app is rendered multiple times during the data fetching process on the server side.
Creating Apollo client instance outside of React
Passing Apollo client instance in props is definitely nicer:
import { ApolloProvider, ApolloClient, NormalizedCacheObject } from "@apollo/client";
import { SnackbarProvider } from "notistack";
import { ActualApp } from "./ActualApp";
interface AppProps {
apolloClient: ApolloClient<NormalizedCacheObject>;
}
const App = ({ apolloClient }: AppProps) => {
return (
<ApolloProvider client={apolloClient}>
<SnackbarProvider>
<ActualApp />
</SnackbarProvider>
</ApolloProvider>
);
};
However, we now have to connect it to the enqueueSnackbar
function. This can be done with a mediator. Let’s create a generic one:
class Dispatcher<TAction> {
// We only need a single subscriber, which is just a callback accepting an action
private subscriber: ((value: TAction) => void) | undefined;
// If there is no subscriber, we store actions to dispatch them later
private pendingActions: TAction[] = [];
public dispatch(action: TAction): void {
if (this.subscriber) {
// Upon dispatch, we simply call the subscriber...
this.subscriber(action);
} else {
// ... or store the action if there is no subscriber
this.pendingActions.push(action);
}
}
public subscribe(subscriber: (value: TAction) => void): () => void {
this.subscriber = subscriber;
// Upon subscription, we dispatch all pending actions
this.pendingActions.forEach((action) => {
this.subscriber?.(action);
});
this.pendingActions = [];
// We return a callback to enable unsubscribing
return () => {
this.subscriber = undefined;
};
}
}
Then we can create a dispatcher for enqueueSnackbar
:
import { ProviderContext } from "notistack";
import { Dispatcher } from "./Dispatcher";
const snackbarDispatcher =
// We don't want global stateful objects on the server side
typeof window !== "undefined" ? new Dispatcher<Parameters<ProviderContext["enqueueSnackbar"]>>() : undefined;
And here is how we can connect it to React:
import { useSnackbar } from "notistack";
import { snackbarDispatcher } from "./snackbarDispatcher";
// This hook should be used once as early as possible, though below SnackbarProvider
function useSnackbarDispatcher(): void {
const { enqueueSnackbar } = useSnackbar();
useEffect(() => {
const unsubscribe = snackbarDispatcher?.subscribe((args) => enqueueSnackbar(...args));
return () => {
unsubscribe?.();
};
}, [enqueueSnackbar]);
}
The idea is to dispatch enqueueSnackbar
arguments from an Apollo client instance and then pass them to enqueueSnackbar
inside React. That is, snackbarDispatcher.dispatch
is essentially equivalent to enqueueSnackbar
.
Note that snackbarDispatcher
is a global stateful object, which is a bad thing for server-side code. Using it on the server side wouldn’t make much sense anyway, but we explicitly create it only on the client side just to be safe.
The useSnackbarDispatcher
hook should be used as early as possible, though below SnackbarProvider
. It might be a good idea to create a custom wrapper for SnackbarProvider
, which can conceal the use of useSnackbarDispatcher
within itself.
The global error handler
As mentioned earlier, there is an intended way to handle errors globally: the onError
link. With it, we can create a global error handler:
import { ErrorResponse } from "@apollo/client/link/error";
import { getNotifications } from "./getNotifications";
import { snackbarDispatcher } from "./snackbarDispatcher";
function globalErrorHandler(error: ErrorResponse) {
if (snackbarDispatcher) {
getNotifications(error).forEach((notification) => snackbarDispatcher.dispatch(notification));
}
}
And use it like this:
import { ApolloClient, from } from "@apollo/client";
import { onError } from "@apollo/client/link/error";
import { globalErrorHandler } from "./globalErrorHandler";
function createApolloClient() {
return new ApolloClient({
// Other options
link: from([
onError(globalErrorHandler),
// Other links
]),
});
}
Let’s see how we can implement the getNotifications
helper.
Parsing ErrorResponse
/ApolloError
We need helpers to determine if the ErrorResponse
object from the global error handler is a network error, and, if not, what are its GraphQL errors.
We also want these helpers to be usable for ApolloError
objects which are returned from e.g. the useQuery
hook. Fortunately, we only need the graphQLErrors
and networkError
fields, which are essentially the same between ErrorResponse
and ApolloError
.
ApolloError
objects can also be thrown, so we type the error
arguments as unknown
.
Finally, we need to address an Apollo bug that causes GraphQL errors to appear in the network error.
import { ServerError, ServerParseError, ApolloError } from "@apollo/client";
import { GraphQLError } from "graphql";
function isNetworkError(error: unknown): error is { networkError: Error | ServerError | ServerParseError } {
return hasNetworkError(error) && !hasGqlErrors(error);
}
function getGqlErrors(error: unknown): GraphQLError[] {
const result: GraphQLError[] = [];
if (hasPopulatedGqlErrors(error)) {
result.push(...error.graphQLErrors);
}
if (hasUnpopulatedGqlErrors(error)) {
result.push(...error.networkError.result.errors);
}
return result;
}
function hasGqlErrors(error: unknown): boolean {
return hasPopulatedGqlErrors(error) || hasUnpopulatedGqlErrors(error);
}
function hasPopulatedGqlErrors(error: unknown): error is { graphQLErrors: ReadonlyArray<GraphQLError> } {
return Boolean(
error &&
typeof error === "object" &&
Array.isArray((error as Partial<ApolloError>).graphQLErrors) &&
(error as ApolloError).graphQLErrors.length
);
}
function hasUnpopulatedGqlErrors(error: unknown): error is {
networkError: { result: { errors: ReadonlyArray<GraphQLError> } };
} {
return Boolean(
hasNetworkError(error) &&
"result" in error.networkError &&
Array.isArray(error.networkError.result.errors) &&
error.networkError.result.errors.length
);
}
function hasNetworkError(error: unknown): error is { networkError: Error | ServerError | ServerParseError } {
return Boolean(error && typeof error === "object" && (error as ApolloError).networkError);
}
export { getGqlErrors, isNetworkError };
And then we can implement the getNotifications
helper:
import { ProviderContext } from "notistack";
import { ErrorResponse } from "@apollo/client/link/error";
import { GqlError } from "./graphql-codegen-result";
import { getGqlErrors, isNetworkError } from "./parsing-helpers";
const NOTIFICATION_MAP: Record<GqlError, Parameters<ProviderContext["enqueueSnackbar"]>> = {
[GqlError.InternalServerError]: ["Something went wrong :(", { variant: "error" }],
};
function getNotifications(error: ErrorResponse) {
if (isNetworkError(error)) {
return ["Check your network connection", { variant: "error" }];
}
return getGqlErrors(error).map(
({ extensions }) =>
NOTIFICATION_MAP[extensions?.code ?? GqlError.InternalServerError] ??
NOTIFICATION_MAP[GqlError.InternalServerError]
);
}
As you can see, there can be either a network error or an array of GraphQL errors. In the latter case, we only rely on the code
field. If it's absent or unknown, we fall back to INTERNAL_SERVER_ERROR
.
GqlError
is an enum with possible error codes generated from the GraphQL Schema (as a reminder, its completeness cannot be guaranteed).
Setting SSR response code
If there was an error during SSR, we shouldn’t return the result with a 200
status code. Therefore, we need a way to tell the renderer that there was an error. This can be done with a custom context. We can create this context on a per-request basis and pass it to globalErrorHandler
via createApolloClient
helper:
import { ApolloClient, from } from "@apollo/client";
import { ErrorResponse, onError } from "@apollo/client/link/error";
import { getNotifications } from "./getNotifications";
import { snackbarDispatcher } from "./snackbarDispatcher";
interface ApolloContext {
statusCode?: number;
}
interface CreateApolloClientOptions {
context?: ApolloContext;
}
interface GlobalErrorHandlerOptions {
context?: ApolloContext;
}
function createApolloClient({ context }: CreateApolloClientOptions = {}) {
return new ApolloClient({
// Other options
link: from([
onError(globalErrorHandler({ context })),
// Other links
]),
});
}
function globalErrorHandler({ context }: GlobalErrorHandlerOptions = {}) {
return (error: ErrorResponse) => {
if (snackbarDispatcher) {
getNotifications(error).forEach((notification) => snackbarDispatcher.dispatch(notification));
}
if (context) {
// We can also set the status code depending on the error code
context.statusCode = 500;
}
};
}
Note that context is only needed in the case of SSR, so it’s optional.
Handling UNAUTHENTICATED
errors
UNAUTHENTICATED
errors can be handled in a similar way, so I leave it as an exercise for the reader. Things to keep in mind:
- Instead of showing a notification, we should redirect to the login page.
- This requires a helper to determine if there is at least one
UNAUTHENTICATED
error and a mediator to connect the global error handler to the app-specific login redirect logic. Sure enough, we can use our generic parsing helpers andDispatcher
for this. - We also have to add a
url
field to the context to enable server-side redirects. - We most likely want to redirect to the original page after successful login. To form a proper login URL on the server side, we need to pass the current location to the global error handler alongside the context.
Overriding the global error handler
Conveniently, there is a built-in way to associate data (and even functionality) with GraphQL operations: operation context (not to be confused with our custom ApolloContext
object). We can put there anything we want:
import { useQuery } from "@apollo/client";
import { MyDocument } from "./MyDocument";
import { context } from "./global-error-handler";
const { data } = useQuery(MyDocument, {
context: context({ disableErrorNotification: true }),
});
And we can adjust the global error handler accordingly:
import { ErrorResponse } from "@apollo/client/link/error";
import { getNotifications } from "./getNotifications";
import { snackbarDispatcher } from "./snackbarDispatcher";
interface OperationContext {
disableErrorNotification?: boolean;
}
function globalErrorHandler(error: ErrorResponse) {
if (snackbarDispatcher) {
const { disableErrorNotification } = error.operation.getContext() as OperationContext;
if (!disableErrorNotification) {
getNotifications(error).forEach((notification) => snackbarDispatcher.dispatch(notification));
}
}
}
// This identity function is used for context typing
function context(context: OperationContext): OperationContext {
return context;
}
More fine-grained control can be achieved by:
- using a callback accepting the
ErrorResponse
object and returning aboolean
; - using something like
{network?: boolean, gql?: Record<GqlError, boolean>}
instead ofboolean
(we can pass it togetNotifications
and filter out notifications that should be ignored).
Unfortunately, there doesn’t seem to be a way to properly type operation context. Usually, it can be done via Declaration Merging, but Apollo types are written in such a way that it’s not possible. One way to mitigate that is to use an identity function for context creation.
Conclusion
Proper error handling in Apollo is a relatively complex task, which requires effort from both backend and frontend teams. Some aspects are open for debate, especially the “where to put errors” problem. Apollo also has some bugs and implementation quirks that make the situation even worse. Even the GraphQL specification itself doesn’t make it easy.
The provided solution is not by any means definitive, but it should be general enough to cover a large number of cases.
Top comments (0)