Apollo link error

:link: Interface for fetching and modifying control flow of GraphQL requests - apollo-link/README.md at master · apollographql/apollo-link
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 errored
  • response: The result returned from lower down in the link chain
  • graphQLErrors: An array of errors from the GraphQL endpoint
  • networkError: Any error during the link execution or server response, that wasn’t delivered as part of the errors field in the GraphQL result
  • forward: A reference to the next link in the chain. Calling return 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:
Diagram of request flow after retrying in error links

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 dataloading, 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. 1. GraphQL errors (like in a previous example)

  2. 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 the data field is set to undefined (even if the server returns some data in response)

  • all — both fields data and graphQLErrors are populated

  • ignore graphQLErrorsfield 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. 1. network errors

  2. 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. 1. application level

  2. 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 and path 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 the path field if we want to know what field caused the error. Since the null 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 its code 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 the extensions 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 codes 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 own stableEnqueueSnackbar with a stable identity, which can call the latest enqueueSnackbar under the hood, but it will make the code even more complex. And if RestOfTheApp 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 and Dispatcher 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 a boolean;
  • using something like {network?: boolean, gql?: Record<GqlError, boolean>} instead of boolean (we can pass it to getNotifications 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.

Понравилась статья? Поделить с друзьями:
  • Aplm0012 an unexpected error has occurred while invoking target service operation
  • Apktowin10m error 8
  • Apktool b error
  • Apksigner returned with error 2 godot
  • Apk установка синтаксическая ошибка