Handling Apollo Errors in React

fenok

Leonid Fenko

Posted on November 13, 2022

Handling Apollo Errors in React

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.

💖 💪 🙅 🚩
fenok
Leonid Fenko

Posted on November 13, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Handling Apollo Errors in React
graphql Handling Apollo Errors in React

November 13, 2022