Handle rate limit errors in Shopify Admin GraphQL API using Apollo client

27 april 2021 door Tolga Paksoy

Shopify uses the leaky bucket algorithm to perform rate limiting on their (Admin) GraphQL API. Yet they do not provide a standard for retrying failed API requests because of these rate limits. They do provide all necessary information to implement rate limiting in their GraphQL responses. Their GraphQL API implements rate limiting with an algorithm called the leaky bucket.

A quick refresher about the leaky bucket algorithm can be found on Shopify’s explainer page about their rate limiting on the Admin GraphQL API: https://shopify.dev/concepts/about-apis/rate-limits#graphql-admin-api-rate-limits.

The rate limit information is stored in the response as an extension. The cost extension to be precise. We can act upon this information using an ErrorLink from the @apollo/client package.

Given a standard ApolloClient setup to query your Shopify Admin API:

// client.js
import {
  ApolloClient,
  HttpLink,
  InMemoryCache,
} from "@apollo/client";

export const createClient = (shop, accessToken) => {
  const http = new HttpLink({
    uri: `https://${shop}/admin/api/2021-04/graphql.json`,
  });

  return new ApolloClient({
    cache: new InMemoryCache(),
    link: http,
  });
};

We can implement an ErrorLink like this:

import { onError } from "@apollo/client/link/error";

function isThrottledError(error) {
  return error.extensions.code === "THROTTLED";
}

const rateLimit = onError(
  ({ graphQLErrors, networkError, forward, operation, response }) => {
    if (networkError) {
      // A non 429 connection error.
      // Fallback to ApolloClient's own error handler.
      return;
    }

    if (!graphQLErrors) {
      // An error we cannot respond to with rate limit handling. We require a specific error extension.
      // Fallback to ApolloClient's own error handler.
      return;
    }

    if (!graphQLErrors.some(isThrottledError)) {
      // There was no throttling for this request.
      // Fallback to ApolloClient's own error handler.
      return;
    }

    const cost = response.extensions.cost;

    if (!cost) {
      // We require the cost extension to calculate the delay.
      // Fallback to ApolloClient's own error handler.
      return;
    }

    if (exceedsMaximumCost(cost)) {
      // Your query costs more than the maximum allowed cost.
      // Fallback to ApolloClient's own error handler.
      return;
    }
    const msToWait = calculateDelayCost(cost);
    operation.setContext({ retry: true, msToWait });

    return delay(msToWait).concat(forward(operation));
  }
);

To implement the ErrorLink above, these functions are needed:

  1. exceedsMaximumCost: will check whether the GraphQL operation failed due to a request that exceeds the maximum cost of 1000
  2. calculateDelayCost: returns the required wait time in milliseconds to be able to perform the GraphQL operation
  3. delay: Apollo links can be composed using Observables from the zen-observable package. delay will be used to make the operation wait until it can be retried

Lets define these functions:

export function exceedsMaximumCost(cost) {
  const {
    requestedQueryCost,
    actualQueryCost,
    throttleStatus: { maximumAvailable },
  } = cost;
  const requested = actualQueryCost || requestedQueryCost;

  return requested > maximumAvailable;
}

export function calculateDelayCost(cost) {
  const {
    requestedQueryCost,
    actualQueryCost,
    throttleStatus: { currentlyAvailable, restoreRate },
  } = cost;

  const requested = actualQueryCost || requestedQueryCost;
  const restoreAmount = Math.max(0, requested - currentlyAvailable);
  const msToWait = Math.ceil(restoreAmount / restoreRate) * 1000;

  return msToWait;
}

function delay(msToWait) {
  return new Observable((observer) => {
    let timer = setTimeout(() => {
      observer.complete();
    }, msToWait);

    return () => clearTimeout(timer);
  });
}

And with this rateLimit link that we just created, we can compose our ApolloClient as following:

// client.js
import {
  ApolloClient,
  HttpLink,
  ApolloLink,
  InMemoryCache,
} from "@apollo/client";

const rateLimit = onError(/*...code above...*/);

export const createClient = (shop, accessToken) => {
  const http = new HttpLink({
    uri: `https://${shop}/admin/api/2021-04/graphql.json`,
  });

  // Compose the rateLimit ErrorLink with the HttpLink into a single link
  const link = new ApolloLink.from([
    rateLimit,
    http
  ]);

  return new ApolloClient({
    cache: new InMemoryCache(),
    link,
  });
};

And you got yourself a retry mechanism for your rate limited requests in Shopify Admin GraphQL API! This rate limit ErrorLink has also been packaged into a handy NPM library: apollo-link-shopify-retry. Just simply run npm install apollo-link-shopify-retry and set it up by following the readme.