Rate limits priced in query cost
Shopify bills per query cost, answers 200 OK when you overspend, and puts the wait in the body. Why status-code retry and rate-per-second both miss, and what does work.
The problem
Shopify's GraphQL Admin API meters a 1,000-point bucket refilling at 50 points/second, and each query has its own price — 11 points for one, 900 for another. Four properties each defeat a different standard tool:
- The unit is cost, not requests. No single requests-per-second is correct for both an 11-point query and a 900-point one.
- Overspending answers
200 OK. The failure arrives in the body as aTHROTTLEDentry inerrors[]— never a429. Retry policies keyed on status see success. - The wait is arithmetic, not a guess. Every response carries
extensions.cost.throttleStatus; the correct wait is(requestedQueryCost − currentlyAvailable) / restoreRate. A backoff curve over-waits when the bucket is full and under-waits when it is empty. - The bucket belongs to the shop, not to you. Another app draining it moves your headroom between two of your own requests (shopify-api-js#602), so a local ledger can never be authoritative — it must be overwritten from every response.
The common solutions
| Approach | Where it breaks |
|---|---|
| Retry on 429 + exponential backoff | Never fires. The response is a 200, and the THROTTLED envelope is returned as data. |
| Body-sniffing retry, then exponential backoff | Fires correctly, then ignores the arithmetic the server already supplied. |
Compute the wait from throttleStatus | Correct — but needs extensions, which GraphQL clients discard when they unwrap data. |
| Local cost ledger, pause below a threshold | Paces your own traffic; blind to other apps on the same shop. |
Fixed rate limiter (N/sec) | Wrong unit. Sized for the worst query it wastes the quota; sized for the average it throttles. |
| Single-worker global queue | Correct and common. Costs concurrency and a piece of infrastructure. |
What StitchAPI does
Not retry, and not throttle — measured, both miss, and the section below says exactly how.
The seam that fits is a custom surface: interpret sees every
response body before the engine decides anything, and the SurfaceOutcome it returns can ask
for a retry after a wait you computed.
import { graphqlSurface, verdictOf } from 'stitchapi';
import type { Surface, SurfaceOutcome } from 'stitchapi';
export function shopifyCostSurface(ledger: CostLedger): Surface {
return {
id: 'graphql',
buildRequest: graphqlSurface.buildRequest,
interpret: (res, cfg): SurfaceOutcome => {
const failure = verdictOf(res, cfg);
if (failure) return failure;
// EVERY response updates the budget — successes carry throttleStatus too,
// and the server's number is authoritative because the bucket is shared.
const cost = costOfBody(res.body);
if (cost) ledger.record(cost);
// The 200-with-THROTTLED, and the wait the server's own arithmetic dictates.
if (isThrottled(res.body) && cost)
return {
ok: false,
retry: true,
message: `THROTTLED — need ${cost.requestedQueryCost}`,
after: deficitWaitMs(cost), // ← (requested − available) / restoreRate
};
return (
graphqlSurface.interpret?.(res, cfg) ?? {
ok: true,
data: res.body,
}
);
},
};
}Pair it with an onRequest hook that pauses while the ledger says the next query is
unaffordable, and the reactive half only handles what the proactive half cannot predict —
another app draining the shop.
Measured: the computed wait was honored exactly (6000 ms, succeeding on attempt 2, where the built-in curve waited 100 ms and failed all three attempts). Against a neighbour emptying the bucket before every single call, 8/8 queries succeeded, absorbing 8 throttles. The whole thing is 73 lines.
StitchAPI vs the common solution
The retry loop stays the engine's. Because the cost logic lives in interpret rather than in
a wrapper around the call, timeout.total still bounds the whole thing, the circuit breaker
still counts failures, and every wait shows up as a retry progress event on the
event stream. The obvious alternative — wrapping the adapter —
was measured going blind: a call that made 2 requests and slept 6 seconds reported
attempts: 1 and emitted zero retry events.
What StitchAPI does not solve here
retry.oncannot see the body. The predicate receives exactly one argument, the status number. A 200-with-THROTTLED is invisible to every built-in retry policy.retry.on: 200retries your successes. The status matcher runs beforeinterpretand cannot tell the two apart — measured 3× the requests and 3× the points for one result.backoffhas no function form. A wait computed from the payload cannot enter through it, and casting past the type error is not a way in either: construction throwsbad backoffrather than silently degrading to the default curve, as it once did (#651, fixed by #666).retry.respectreads aRetry-Afterheader that Shopify never sends.throttle.ratecannot express cost. Requests-per-interval only, minimum spacing, no burst. Approximating the 1,000-point bucket took 18 s for work the bucket absorbs instantly — and with mixed costs no single spacing is correct for both.throttle.delegateis status-keyed. The escape hatch the throttle docs point at for vendor-accounted quotas does not reach a body-reported one.- A rejected body never reaches the caller. On a surface failure the
StitchErrorhas nobody, the error event has no field for one, and.inspect().rawisnull. Whatever you need from the body must be captured insideinterpretorhooks.onResponse. interpretis synchronous. A distributed cost ledger cannot live in the seam that otherwise solves this — single process only.
Do not reach for verdict.flag here. It is the one built-in that reads
the body for a verdict, so it is the natural guess — and on a throttled
Shopify response it is inert: the payload has no data key, an absent
path is "no signal", the 200 stands, and the call returns ok: true with
the THROTTLED envelope as your data. Silent, and it looks like a
successful sync.
See also
OAuth2 refresh tokens that rotate
A single-use refresh token plus two concurrent workers revokes the whole account. What the usual fixes cost, and what a custom auth strategy buys you.
Batch writes that fail one item at a time
A bulk endpoint returns 200 and reports that 7 of your 100 items did not land. Retrying the request re-writes the 93 that did — so the retry unit has to be the body, not the call.