title | description |
---|---|
apollo-link-error |
Handle and inspect errors in your GraphQL network stack. |
Use this link to do some custom logic when a GraphQL or network error happens:
import { onError } from "apollo-link-error"; const link = onError(({ graphQLErrors, networkError }) => { if (graphQLErrors) graphQLErrors.forEach(({ message, locations, path }) => console.log( `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}` ) ); if (networkError) console.log(`[Network error]: ${networkError}`); });
Apollo Link is a system of modular components for GraphQL networking. Read the docs to learn how to use this link with libraries like Apollo Client and graphql-tools, or as a standalone client.
Callback
Error Link takes a function that is called in the event of an error. This function is called with an object containing the following keys:
operation
: The Operation that erroredresponse
: The result returned from lower down in the link chaingraphQLErrors
: An array of errors from the GraphQL endpointnetworkError
: Any error during the link execution or server response, that wasn’t delivered as part of theerrors
field in the GraphQL resultforward
: A reference to the next link in the chain. Callingreturn forward(operation)
in the callback will retry the request, returning a new observable for the upstream link to subscribe to.
Returns: Observable<FetchResult> | void
The error callback can optionally return an observable from calling forward(operation)
if it wants to retry the request. It should not return anything else.
Error categorization
An error is passed as a networkError
if a link further down the chain called the error
callback on the observable. In most cases, graphQLErrors
is the errors
field of the result from the last next
call.
A networkError
can contain additional fields, such as a GraphQL object in the case of a failing HTTP status code from apollo-link-http
. In this situation, graphQLErrors
is an alias for networkError.result.errors
if the property exists.
Retrying failed requests
An error handler might want to do more than just logging errors. You can check for a certain failure condition or error code, and retry the request if rectifying the error is possible. For example, when using some form of token based authentication, there is a need to handle re-authentication when the token expires. Here is an example of how to do this using forward()
.
onError(({ graphQLErrors, networkError, operation, forward }) => { if (graphQLErrors) { for (let err of graphQLErrors) { switch (err.extensions.code) { case 'UNAUTHENTICATED': // error code is set to UNAUTHENTICATED // when AuthenticationError thrown in resolver // modify the operation context with a new token const oldHeaders = operation.getContext().headers; operation.setContext({ headers: { ...oldHeaders, authorization: getNewToken(), }, }); // retry the request, returning the new observable return forward(operation); } } } if (networkError) { console.log(`[Network error]: ${networkError}`); // if you would also like to retry automatically on // network errors, we recommend that you use // apollo-link-retry } } );
Here is a diagram of how the request flow looks like now:
One caveat is that the errors from the new response from retrying the request does not get passed into the error handler again. This helps to avoid being trapped in an endless request loop when you call forward() in your error handler.
Ignoring errors
If you want to conditionally ignore errors, you can set response.errors = undefined;
within the error handler:
onError(({ response, operation }) => { if (operation.operationName === "IgnoreErrorsQuery") { response.errors = undefined; } });
If you use Graphql Apollo Client with React, there are two ways (more precisely speaking) – two levels of handling errors:
-
— operation level
-
— application level
Operation level errors handling
In this case, you have access to the data, loading, and error fields, and you can use an error object, which can be used to show a conditional error message.
1const { loading, error, data } = useQuery(YOUR_QUERY);
2
3if (error) return <p>Error :(</p>;
Of course, you can create a component responsible for displaying errors in your app.
1import React from 'react';
2import PropTypes from 'prop-types';
3
4import classes from './ErrorMessage.module.css';
5
6
7const ErrorMessage = (props) => {
8 const { error, ...rest } = props;
9
10 const shouldDisplayError = error && error.message ?
11 <div className={classes.errorMessage} {...rest}>{error.message}</div>
12 :
13 null
14 ;
15
16 return shouldDisplayError;
17};
18
19ErrorMessage.propTypes = {
20 error: PropTypes.shape({
21 message: PropTypes.string.isRequired
22 })
23};
24
25export default ErrorMessage;
This component is really straightforward. It receives an error as a prop and displays that error to the user.
Application-level error handling
Another approach is handling errors in Application-level. It allows you to create more complex logic.
Application-error handling lets you do whatever you want with errors. For example, you can log those errors to the console in development mode or use external tracking error tools like Sentry on production.
You can use this mechanism to display messages to the user as well. Let’s imagine that you have Messages Context in your app or you have a custom hook, and there you keep the whole logic for adding/removing/displaying messages.
If you use application-level error handling, you can pass error messages to your messages/notification manager and do whatever you want with them.
There are two types of errors.
-
1. GraphQL errors (like in a previous example)
-
2. Network error (for example, if the app lost internet connection)
GraphQL errors
There are three types of GraphQL errors:
-
— syntax error — for example, when you made a mistake in a query or mutation
-
— resolver error — for example, when the GraphQL server was not able to resolve a query field
-
— validation error — for example, when provided data didn’t pass validation on the server side.
Note that when there is a resolver error, the GraphQL server returns partial data, but if there is a syntax or validation error, the server doesn’t return data at all.
In the first case, the server responds with a 200 status code, otherwise returns a 4xx status code (for syntax and validation errors)
Network errors
Network errors occur when there are communication problems with the GraphQL server. In this case, the server usually responds with a 4xx or 5xx response status code and no data.
Error policies
By default, the Apollo server returns partial data when there is a resolver error, but you can change this behavior by changing the error policy. There are three error policies:
-
— none — the default one — if there are errors the
graphQLErrorsthe
field is populated and thedata
field is set to undefined (even if the server returns some data in response) -
— all — both fields
data
andgraphQLErrors
are populated -
— ignore —
graphQLErrors
field is ignored and not populated
How to specify error policy
You can specify error policy globally on or query/mutation level.
Global error policy
You can set an error policy for queries and mutations using the defaultOptions
object in the ApolloClient constructor. The example below shows an error policy all set for queries and an ignore policy for mutations.
1import { ApolloClient, InMemoryCache } from '@apollo/client';
2
3const client = new ApolloClient({
4 cache: new InMemoryCache(),
5 uri: 'http://localhost:3000/',
6 defaultOptions: {
7 query: {
8 errorPolicy: 'all',
9 },
10 mutate: {
11 errorPolicy: 'ignore',
12 },
13 },
14});
15
Operation error policy
To specify error policy on the operation level, you have to pass the errorPolicy
field in options object like this:
1const { loading, error, data } = useQuery(YOUR_QUERY, { errorPolicy: "ignore" });
Implement application-level error handling
To implement application-level error handling, we need to use a functionality called ApolloLink.
The Apollo Link library helps you customize the data flow between Apollo Client and your GraphQL server. You can define your client’s network behavior as a chain of link objects that execute in a sequence.
Each link should represent either a self-contained modification to a GraphQL operation or a side effect (such as logging).
Take a look at a sample implementation of application-level error handling.
First, import the onError function.
1import { onError } from "@apollo/client/link/error";
Second, create the errorLink:
1const errorLink = onError(({ graphQLErrors, networkError }) => {
2 if (graphQLErrors) {
3 console.log(graphQLErrors);
4 }
5
6 if (networkError) {
7 // handle network error
8 console.log(networkError);
9 }
10});
Third, use HttpLink, and from helper method to combine a single link that can be used in the Apollo client.
1import { ApolloClient, InMemoryCache, ApolloProvider, from, HttpLink } from '@apollo/client';
2
3...
4
5const httpLink = new HttpLink({ uri: 'https://<API_URL>' })
6
7const appLink = from([
8 errorLink, httpLink
9])
10
11const client = new ApolloClient({
12 link: appLink,
13 cache: new InMemoryCache(),
14
15});
Summary
There are two types of errors that you can handle:
-
1. network errors
-
2. GraphQL error
There are three error policies (all, ignore, and none), and you can specify an error policy globally or on the operation level.
Moreover, there are two levels where you can handle those errors:
-
1. application level
-
2. component (query/mutation) level
Thanks to application-level error handling, you can use JavaScript error tracking tools on production and log errors to the console in local environments. Besides, you can use this mechanism to handle and display errors in your application.
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"
}
}
]
}
Enter fullscreen mode
Exit fullscreen mode
- 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>
);
Enter fullscreen mode
Exit fullscreen mode
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>;
};
Enter fullscreen mode
Exit fullscreen mode
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>
);
};
Enter fullscreen mode
Exit fullscreen mode
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>
);
};
Enter fullscreen mode
Exit fullscreen mode
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;
};
}
}
Enter fullscreen mode
Exit fullscreen mode
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;
Enter fullscreen mode
Exit fullscreen mode
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]);
}
Enter fullscreen mode
Exit fullscreen mode
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));
}
}
Enter fullscreen mode
Exit fullscreen mode
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
]),
});
}
Enter fullscreen mode
Exit fullscreen mode
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 };
Enter fullscreen mode
Exit fullscreen mode
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]
);
}
Enter fullscreen mode
Exit fullscreen mode
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;
}
};
}
Enter fullscreen mode
Exit fullscreen mode
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 }),
});
Enter fullscreen mode
Exit fullscreen mode
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;
}
Enter fullscreen mode
Exit fullscreen mode
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.