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.
| Plan | Bucket size | Refill per second | Sustained mutations per second |
|---|---|---|---|
| Standard | 1,000 | 50 | 5 |
| Advanced | 2,000 | 100 | 10 |
| Plus | 10,000 | 500 | 50 |
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.