Shopify GraphQL error handling in Python
Why failed calls return 200 OK - top-level errors, mutation userErrors and a reusable checking pattern.
Overview
Shopify returns HTTP 200 OK for many calls that failed, with the details nested in the response body. Handlers that branch on status codes silently swallow those failures.
This is because GraphQL treats HTTP as a transport, not as a verdict. The HTTP layer only says the server answered - whether the operation succeeded lives inside the response body, and Shopify uses 200 OK for many conditions that REST would express as a 4xx or 5xx. Rate limiting is the canonical example - a THROTTLED rejection is a 200 with an errors array in the body.
Clients written with REST habits - if response.status_code == 200, assume success - therefore silently swallow failures. Teams migrating to Shopify's GraphQL report exactly this: error volumes that spiked unnoticed because nothing ever looked like an error to their HTTP layer.
The two error channels
Errors appear in two separate places, and a robust service checks both.
| Channel | Where it lives | What it contains | Examples |
|---|---|---|---|
| Top-level errors | The errors array next to data | Query-level failures - the operation did not run or ran partially | THROTTLED, syntax errors, access-scope denials, internal errors |
| userErrors | Inside each mutation's payload in data | Validation verdicts - the mutation ran and rejected the input | Blank required field, key must be unique, limit exceeded |
The split makes sense once seen from Shopify's side: errors means the request itself misfired, userErrors means the request was fine and the answer is no. A mutation with a userErrors entry has done nothing to the shop - but reported it via a perfectly successful HTTP exchange.
What the client already does for you
The GraphQL connection is built on the gql library, and conn.execute raises a TransportQueryError whenever the response contains top-level errors - the first channel is therefore an exception in Python, impossible to miss as long as you do not catch it blindly. Catch it narrowly and inspect it: THROTTLED deserves a retry with backoff as the rate limits guide shows, while anything else should propagate and fail loudly.
Checking userErrors
The second channel is yours to check - no library can know that an empty userErrors list is the success criterion of your mutation. The rule is mechanical: every mutation selects userErrors { field message } in its payload, and every service raises if the list is non-empty.
# -*- coding: utf-8 -*-
# Zato
from zato.server.service import Service
class UpdateProductTitle(Service):
name = 'shopify.update-product-title'
input = 'product_id', 'title'
def handle(self):
conn = self.out.graphql['Shopify']
mutation = """
mutation Rename($input: ProductInput!) {
productUpdate(input: $input) {
product { id title }
userErrors { field message }
}
}
"""
params = {'input': {
'id': self.request.input.product_id,
'title': self.request.input.title,
}}
# Transport and top-level errors raise here ..
result = conn.execute(mutation, params=params)
# .. but validation failures arrive as data
user_errors = result['productUpdate']['userErrors']
if user_errors:
raise Exception(f'productUpdate failed: {user_errors}')
Make it a helper and the rule costs one line per mutation:
# -*- coding: utf-8 -*-
# Zato
from zato.server.service import Service
class ShopifyService(Service):
""" A base class for services that run Shopify mutations.
"""
def run_mutation(self, mutation, params, payload_name):
conn = self.out.graphql['Shopify']
result = conn.execute(mutation, params=params)
# Every Shopify mutation payload includes userErrors -
# a non-empty list means the input was rejected
payload = result[payload_name]
user_errors = payload['userErrors']
if user_errors:
raise Exception(f'{payload_name} failed: {user_errors}')
return payload
Each userErrors entry has a field path pointing into your input and a human-readable message - for example field ['variants', '0', 'sku'] with message 'Key must be unique within this namespace'. Log both, they name the exact offending value.
Note that userErrors belongs to mutation payloads only - queries fail through the top-level errors channel, which conn.execute already turns into an exception.
Shopify's own failures
Internal errors and timeouts exist too - "Internal error. Looks like something went wrong on our end" and plain connection timeouts show up in every production integration, with visible spikes during Shopify-side incidents. Treat them as transient: one retry with a pause is reasonable, more than that is noise, and persistent recurrence belongs on a dashboard, not in a longer retry loop. Log the correlation ID - self.cid - with every failure so a burst of them can be traced end to end.
The invariant worth keeping is this: a Shopify call in your codebase either returns verified data or raises - there is no third path where a failure travels onward disguised as a result. Both channels checked, everything else is normal Python exception handling.