Shopify rate limits and backoff in Python

The cost-based leaky bucket, the 1,000-point single-query ceiling and retrying THROTTLED errors with backoff.

Overview

The Admin API does not count requests, it counts points. Your app has a bucket of 1,000 points per shop on the standard plan - 2,000 on Advanced, 10,000 on Plus - and it refills continuously at 50, 100 or 500 points per second depending on the plan. Every GraphQL request drains the bucket by its calculated cost, and a request that does not fit is rejected with a THROTTLED error until the bucket refills.

The cost of a query is deterministic: scalar fields are free, object fields cost 1 point, connection fields cost 2 points plus the number of items you request, and a mutation costs 10. On top of the bucket there is a hard ceiling - no single query may cost more than 1,000 points, on any plan. A query that requests too much fails immediately with an error such as "Query cost is 1040, which exceeds the single query max cost limit (1000)", no matter how full the bucket is.

PlanBucket sizeRefill per secondSustained mutations per second
Standard1,000505
Advanced2,00010010
Plus10,00050050

Writing queries with cost in mind

Three habits keep services out of trouble:

  • Request only the fields you use - cost scales with the selection, and a lean query can be an order of magnitude cheaper than a greedy one
  • Size the page arguments consciously - first: 250 items on a connection with nested sub-connections multiplies the cost fast, and 250 is also the per-page item cap
  • Treat sustained high-volume work as a bulk operation - catalog imports, backfills and analytics reads sidestep the bucket entirely

Every response includes the actual numbers in its extensions.cost block - requestedQueryCost, actualQueryCost and the throttleStatus with currentlyAvailable points. During development, log that block once for each new query and you will know exactly what it costs before it meets production traffic.

Handling THROTTLED

Retry with exponential backoff, and retry only THROTTLED - other errors mean the query itself is wrong and repeating it changes nothing. The GraphQL connection raises a TransportQueryError when the response contains errors, so the pattern is a loop around conn.execute that doubles the wait after each throttled attempt and re-raises everything else.

# -*- coding: utf-8 -*-

# stdlib
from time import sleep

# gql
from gql.transport.exceptions import TransportQueryError

# Zato
from zato.server.service import Service

# How many times to retry a throttled call
Max_Attempts = 5

# How long to wait before the first retry, in seconds
Initial_Wait = 1.0

class GetOrdersWithBackoff(Service):
    name = 'shopify.get-orders-with-backoff'

    def handle(self):

        conn = self.out.graphql['Shopify']

        query = """
            {
                orders(first: 50) {
                    edges { node { id name } }
                }
            }
        """

        wait = Initial_Wait

        for attempt in range(Max_Attempts):
            try:
                result = conn.execute(query)
                break
            except TransportQueryError as e:

                # Only THROTTLED is retryable - anything
                # else is a real error to raise
                if 'THROTTLED' not in str(e):
                    raise

                self.logger.info('Throttled, retry %s in %ss', attempt + 1, wait)
                sleep(wait)
                wait = wait * 2

        self.response.payload = result

Where many callers share one shop - several services, several servers - the polite total throughput is still one bucket, so put the retry pattern in the one service that owns the Shopify write path rather than sprinkling calls across the codebase. A single owner service also gives you one place to log costs and one place to change the strategy.

Planning a large job

Arithmetic first, code second. Mutations cost 10 points, so at 50 points per second a standard-plan shop sustains 5 mutations per second - updating 10,000 variants one product at a time is a 33-minute floor, before any other traffic. If that number is unacceptable, the design is wrong, not the retry code: switch the job to a bulk operation, or batch more work into each mutation, as productSet and productVariantsBulkUpdate allow - see products and variants.

Note that THROTTLED is not an HTTP error - the response is HTTP 200 OK with THROTTLED inside the errors array, which is why status-code-based error handling misses it. The error handling guide covers this trap in full.

Learn more