GraphQL promises precision. You ask for exactly the fields you need, and Shopify returns exactly that shape. But the moment your app scales past a handful of requests, you discover something uncomfortable. GraphQL does not fail the way you expect. A request can return HTTP 200 and still carry a fatal error buried inside the response body. A query can succeed partially. A throttle can arrive mid-mutation. If your client treats GraphQL like a simple REST endpoint, it will break in production.
This guide walks through practical Shopify GraphQL error recovery patterns that keep integrations stable under real traffic. We will cover how Shopify surfaces failures, how to classify them, and how to build retry, fallback, and recovery logic that holds up when things go wrong.
Why GraphQL Error Handling Differs From REST
In REST, status codes do most of the talking. A 404 means missing, a 429 means throttled, a 500 means the server broke. You can branch your logic on the status code alone.
GraphQL changes the contract. Shopify often returns HTTP 200 even when the operation failed. The real signal lives inside the JSON payload. That means you cannot rely on transport-level status codes alone. You have to parse the body, inspect the errors array, and check userErrors on mutations.
This single difference trips up most teams migrating from the older API. If you are in that camp, our walkthrough on migrating large systems from REST to GraphQL covers the structural shifts in depth. Here we focus on what happens after the response lands.
The Three Layers of GraphQL Failure
Every GraphQL response can fail at three distinct layers. Treating them as one bucket is the most common mistake in Shopify API failure patterns.
| Layer | Where it appears | Example cause |
|---|---|---|
| Transport | HTTP status, network | Timeout, 5xx, DNS failure |
| Query | errors array in body |
Throttled, invalid field, cost exceeded |
| Business | userErrors on mutations |
Invalid SKU, duplicate handle, missing permission |
Each layer needs a different recovery response. A transport error usually deserves a retry. A query-level throttle needs a backoff. A business-level userErrors entry should almost never be retried, because the input itself is wrong.
Let us look at how to detect each one.
Reading Transport Level Errors
Transport errors are the easiest to spot and the most tempting to over-retry. A 500 or 503 from Shopify often reflects a transient blip. A network timeout might mean your request never arrived, or it arrived and the response got lost.
Here is a basic classifier in JavaScript.
function classifyTransport(response, networkError) {
if (networkError) {
return { type: "network", retryable: true };
}
const status = response.status;
if (status === 429) return { type: "throttled", retryable: true };
if (status >= 500) return { type: "server", retryable: true };
if (status === 401 || status === 403) {
return { type: "auth", retryable: false };
}
return { type: "ok", retryable: false };
}
Notice that auth failures are marked non-retryable. Retrying a 401 just wastes your rate budget. Fix the token instead. For a deeper view on structuring retries around these signals, see our breakdown of retry architecture for Shopify failures.
Parsing Query Level Errors
This is where GraphQL gets interesting. A 200 response can still contain a fatal problem. The errors array tells you what went wrong at the query level.
{
"errors": [
{
"message": "Throttled",
"extensions": {
"code": "THROTTLED",
"documentation": "https://shopify.dev/api/usage/rate-limits"
}
}
]
}
The extensions.code field is your friend. It gives you a machine-readable reason. Common codes include THROTTLED, MAX_COST_EXCEEDED, and ACCESS_DENIED.
Your handler should branch on these codes rather than on message strings, which can change.
function classifyQueryErrors(body) {
if (!body.errors) return { type: "ok", retryable: false };
const codes = body.errors.map(e => e.extensions?.code);
if (codes.includes("THROTTLED")) {
return { type: "throttled", retryable: true };
}
if (codes.includes("MAX_COST_EXCEEDED")) {
return { type: "cost", retryable: false };
}
return { type: "query_error", retryable: false };
}
A MAX_COST_EXCEEDED error is non-retryable for a reason. Retrying the same oversized query produces the same failure. Instead, you need to reduce the query cost. Our guide on Shopify GraphQL query cost optimization explains how to trim fields and split heavy operations before they ever fail.
Handling Throttling and Cost Signals
Shopify uses a calculated query cost model with a leaky bucket. Every GraphQL response includes a cost object under extensions that tells you exactly where you stand.
{
"extensions": {
"cost": {
"requestedQueryCost": 101,
"actualQueryCost": 46,
"throttleStatus": {
"maximumAvailable": 1000,
"currentlyAvailable": 954,
"restoreRate": 50
}
}
}
}
A resilient client reads throttleStatus on every successful response, not just on failures. By tracking currentlyAvailable against the cost of your next query, you can slow down before you hit the wall. This proactive approach beats reactive retrying every time.
We go deep on this leaky-bucket math in GraphQL rate limit engineering and on broader rate limit recovery strategies. The short version is simple. When currentlyAvailable drops below your next query cost, wait for the bucket to refill based on restoreRate before sending.
Backoff With Jitter
When you do retry a throttled or server error, never retry instantly and never retry in lockstep. If a thousand clients all back off for exactly two seconds, they stampede the API at the same moment.
Exponential backoff with jitter spreads the load.
async function retryWithBackoff(fn, maxAttempts = 5) {
let attempt = 0;
while (attempt < maxAttempts) {
const result = await fn();
if (result.retryable !== true) return result;
attempt++;
const base = Math.min(1000 * 2 ** attempt, 30000);
const jitter = Math.random() * base;
await new Promise(r => setTimeout(r, base + jitter));
}
throw new Error("Max retry attempts exceeded");
}
The jitter term is what prevents synchronized retries. Combine this with the throttle-aware logic above, and your client adapts to Shopify’s actual capacity instead of guessing. These patterns sit at the heart of building high-performance Shopify API clients.
Handling Business Errors With userErrors
Mutations introduce a fourth consideration. Even when a mutation runs cleanly at the transport and query layers, the business logic can reject your input. Shopify surfaces this through the userErrors field.
mutation {
productUpdate(input: { id: "gid://shopify/Product/123", title: "" }) {
product { id }
userErrors {
field
message
}
}
}
An empty title returns a userErrors entry, not a top-level error. Your code must always request userErrors on every mutation and check it before treating the operation as a success.
Here is the critical rule. A userErrors entry is almost never retryable. The input is wrong. Retrying sends the same bad data again. Route these to a validation log or a dead letter queue instead. Our piece on dead letter queues for Shopify webhooks applies the same pattern to failed operations that need human review.
Partial Success in Batched Queries
GraphQL lets you request multiple fields in one call. When one field fails, Shopify can return data for the fields that succeeded and an error for the one that did not. The response carries both data and errors at the same time.
{
"data": {
"product": { "title": "Shirt" },
"collection": null
},
"errors": [
{ "message": "Collection not found", "path": ["collection"] }
]
}
A naive client that checks only for the presence of errors would throw away the valid product data. A smart client reads the path on each error, discards only the failed branch, and keeps the rest. This partial-recovery mindset matters most when you run parallel query optimization techniques that fan out many fields at once.
Idempotency and Safe Retries
Retrying a read is harmless. Retrying a write is dangerous. If your first mutation actually succeeded but the response got lost in transit, a blind retry creates duplicates.
The fix is idempotency. Attach a stable identifier to each operation and track which ones have completed. On retry, check the tracker first. If the operation already succeeded, skip the retry and return the cached result.
| Operation type | Safe to retry blindly | Needs idempotency key |
|---|---|---|
| Query (read) | Yes | No |
| Mutation (create) | No | Yes |
| Mutation (update) | Depends | Recommended |
| Bulk operation | No | Yes |
For writes that create records, a duplicate order or product is a real business cost. Our guide on idempotency strategies in Shopify systems shows how to build this safety net, and handling duplicate Shopify events covers the same concern from the webhook side.
Circuit Breakers for Repeated Failures
Retries help with transient problems. They hurt when the failure is sustained. If Shopify is down or your token is revoked, retrying thousands of times just burns resources and delays recovery.
A circuit breaker watches the failure rate. After a threshold of consecutive failures, it trips open and stops sending requests for a cooldown period. During that window, requests fail fast instead of hanging. After the cooldown, it lets a trial request through to test recovery.
class CircuitBreaker {
constructor(threshold = 5, cooldown = 30000) {
this.failures = 0;
this.threshold = threshold;
this.cooldown = cooldown;
this.openUntil = 0;
}
canRequest() {
return Date.now() > this.openUntil;
}
record(success) {
if (success) {
this.failures = 0;
} else if (++this.failures >= this.threshold) {
this.openUntil = Date.now() + this.cooldown;
this.failures = 0;
}
}
}
This pattern pairs naturally with retries. The breaker decides whether to attempt at all, and the retry logic handles individual attempts. We cover the full design in circuit breaker patterns for Shopify APIs, and it is a key tool for preventing cascading failures in Shopify apps.
Falling Back Gracefully
Not every failure should bubble up to the user. When a non-critical field fails, serve stale cached data instead of an error page. When a product image query times out, show a placeholder.
This is where a caching layer earns its keep. A client that reads from cache on failure degrades gracefully instead of breaking. Our deep dive on caching strategies for Shopify GraphQL explains how to structure a cache that doubles as a fallback source during outages.
Fallback logic should follow a clear priority order:
- Return fresh data from the successful response.
- On failure, return recent cached data if available.
- If no cache exists, return a safe default.
- Only surface an error when none of the above apply.
Putting It Together in a Resilient Client
A resilient GraphQL client layers these patterns in a specific order. Each layer wraps the one below it.
| Order | Layer | Responsibility |
|---|---|---|
| 1 | Circuit breaker | Decide whether to attempt at all |
| 2 | Rate limiter | Check throttle budget before sending |
| 3 | Retry with backoff | Handle transient failures |
| 4 | Error classifier | Sort transport, query, and user errors |
| 5 | Idempotency guard | Prevent duplicate writes on retry |
| 6 | Cache fallback | Serve stale data when all else fails |
This layered model is the backbone of designing resilient Shopify middleware and underpins any fault-tolerant Shopify integration. When you run bulk jobs, the same classifier logic applies to the asynchronous status checks described in our Bulk Operations API at scale guide.
Bulk Operations and Async Error Recovery
Bulk operations change the error model again. You submit a job, poll for completion, and download a result file. Errors can appear at submission, during processing, or in the result rows themselves.
A bulk job can fail entirely, or it can complete with some records errored inside the output file. Your recovery logic has to inspect the status field on the operation and then scan the result file for per-row errors.
For catalogs with hundreds of thousands of items, this matters a lot. Our guide on handling large product catalog synchronization walks through the full polling and partial-failure flow. The principle is the same as synchronous queries. Separate the job-level failure from the record-level failure, and recover each differently.
Observability and Self-Healing
You cannot recover from errors you cannot see. Every classified error should emit a structured log entry with the error type, the query cost, the throttle status, and the retry count. Over time, these logs reveal patterns, like a specific query that consistently exceeds cost or a token that keeps expiring.
Mature teams feed these signals into automated remediation. A query that repeatedly throttles triggers an automatic cost reduction. A token that fails auth triggers a refresh. This is the foundation of building self-healing Shopify integrations, and it ties directly into solid Shopify incident response practices when something does slip through.
Choosing the Right Pattern for Each Error
There is no single recovery strategy. The right move depends on the error class. Use this as a quick reference.
| Error signal | Recommended action |
|---|---|
| Network timeout | Retry with backoff plus idempotency |
| HTTP 5xx | Retry with backoff, trip breaker on repeats |
| THROTTLED | Wait for bucket refill, then retry |
| MAX_COST_EXCEEDED | Reduce query cost, do not retry |
| ACCESS_DENIED | Fix scopes or token, do not retry |
| userErrors present | Log, send to review queue, do not retry |
| Partial data plus errors | Keep valid branches, recover failed ones |
Match the response to the signal, and your integration stays stable even when Shopify or the network misbehaves. For teams running at serious volume, these patterns combine into the broader toolkit for scaling Shopify apps to millions of requests and designing scalable Shopify integration patterns.
Conclusion
Shopify GraphQL error recovery is not about catching a single exception. It is about understanding that failures arrive in layers, each with its own signal and its own correct response. Transport errors want retries. Throttles want backoff. Cost errors want smaller queries. Business errors want validation, not repetition.
Build your client in layers. Start with a classifier that reads status codes, the errors array, and userErrors. Add backoff with jitter. Wrap it in a circuit breaker. Guard your writes with idempotency. Fall back to cache when everything else fails. Do that, and your integration will keep running long after a naive client would have fallen over.
Frequently Asked Questions
Why does Shopify return HTTP 200 on a failed GraphQL request?
GraphQL uses the response body, not the status code, to report query and business errors. A 200 means the request reached the server and was parsed. You still have to check the errors array and any userErrors fields to confirm the operation actually succeeded.
Should I retry every GraphQL error?
No. Retry transient failures like network timeouts, 5xx responses, and throttles. Never retry validation errors in userErrors or a MAX_COST_EXCEEDED error, because the same request will fail again. Retrying those just wastes your rate budget.
How do I avoid duplicate records when retrying a mutation?
Attach an idempotency key to each write and track completed operations. On retry, check the tracker first and skip the operation if it already succeeded. This prevents duplicate products, orders, or customers when a response gets lost after a successful write.
What is the difference between errors and userErrors?
The top-level errors array reports query-level problems like throttling or invalid fields. The userErrors field on a mutation reports business validation failures like an empty title or a duplicate handle. Always request and check both.
How does a circuit breaker help with GraphQL recovery?
A circuit breaker stops sending requests after repeated failures, letting the system fail fast instead of hanging. It gives Shopify or your network time to recover and prevents a flood of doomed retries from making an outage worse.
What should I do when a query exceeds the cost limit?
Do not retry it. Reduce the query by requesting fewer fields, using pagination, or splitting it into smaller operations. The cost model is deterministic, so the same oversized query will always exceed the limit.
