Retry gateway errors on idempotent GraphQL requests - #8450
Open
craigmichaelmartin wants to merge 1 commit into
Open
Retry gateway errors on idempotent GraphQL requests#8450craigmichaelmartin wants to merge 1 commit into
craigmichaelmartin wants to merge 1 commit into
Conversation
A 502/503/504 from the proxy in front of a Shopify API was classified as a terminal `client-error` in `retryAwareRequest` and thrown on the first attempt, so a single gateway blip killed the whole command. Observe error 12477344795764185659 (shop/issues#73647, Vault 68247) shows this hitting 35 users in 7 days on `getThemes` alone, with a matching signature on the store `execute` path (shop/issues#73646). Both surface the raw graphql-request `ClientError`, so the user sees a wall of nginx HTML. Classify a gateway status as retryable, but only when the request is known to be idempotent. `graphqlRequest`/`graphqlRequestDoc` mark a document as idempotent when it holds exactly one `query` operation, so a mutation is never resent — a 502 can mean the upstream processed the request and only the response was lost, and a 504 means the gateway did forward it to an origin that may have been mid-write. Gateway retries get their own budget of 3 rather than the default 10: unlike a 429, a gateway error carries no instruction about when to come back, and an upstream that is already failing should not be hit ten more times on its way down. It is a separate counter rather than an override of `limitRetriesTo`, so an explicit caller cap still composes. Also stop the network-level retry from matching a `ClientError` by message text. That loop does see the thrown `ClientError` and tests it with `isTransientNetworkError`, which searches `error.message` — and a `ClientError`'s message embeds `JSON.stringify({response, request})`, so the request's own query and variables are part of the searched string. A theme asset containing `setTimeout` matches `'timeout'`, which made a 502 on the `ThemeFilesUpsert` mutation retry non-idempotently purely because of the file's contents. A `ClientError` means a response came back, so it is never a connection-level failure; retryable statuses are now classified deliberately where the idempotency of the request is known. Verified: with the guard removed the new regression test re-POSTs the mutation until the retry window expires. Hoisting the `Retry-After` parse out of the throttling branch also fixes a latent bug on that path: the old inline `Number.parseInt` could yield `NaN` for the HTTP-date form of the header, and `??` does not treat `NaN` as absent, so `NaN` reached `setTimeout` and fired immediately. It is now normalised to `undefined` so the default delay applies. `isGatewayErrorStatus` lives in a new dependency-free module so the crash-report suppression in `public/node/error.ts` can share it without pulling `graphql-request` into every command's module graph (#8329 covers that half). HTTP 500 is deliberately excluded: the API itself answered and failed, so it keeps its existing non-retryable, reportable behaviour. This covers the GraphQL path only — on the plain-fetch path node-fetch resolves a 502 as a normal response, which needs a separate decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Assisted-By: devx/19e8dac7-0b4c-4b28-9e46-57a96d991bbd
Contributor
Differences in type declarationsWe detected differences in the type declarations generated by Typescript for this branch compared to the baseline ('main' branch). Please, review them to ensure they are backward-compatible. Here are some important things to keep in mind:
New type declarationspackages/cli-kit/dist/private/node/api/gateway-status.d.ts/**
* Pure helpers for classifying HTTP statuses that come from the gateway in front of an API rather
* than from the API itself.
*
* This module is intentionally dependency-free so that both the request layer (`../api.ts`) and the
* crash-report suppression logic in `../../public/node/error.ts` can share it. `error.ts` cannot
* import `../api.ts` directly — that would pull `graphql-request` into the module graph of every
* command, and `api.ts → headers.ts → error.ts` is already a cycle — so the shared status logic
* lives here, where it imports nothing from cli-kit.
*/
/**
* Whether an HTTP status indicates a gateway-level failure in front of the API.
*
* @param status - The HTTP status of the response, if known.
* @returns True when the status is 502, 503 or 504.
*/
export declare function isGatewayErrorStatus(status: number | undefined): boolean;
Existing type declarationspackages/cli-kit/dist/private/node/api.d.ts@@ -12,6 +12,7 @@ export type NetworkRetryBehaviour = {
type RequestOptions<T> = {
request: () => Promise<T>;
url: string;
+ requestIsIdempotent?: boolean;
} & NetworkRetryBehaviour;
/**
* Checks if an error is a transient network error that is likely to recover with retries.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resolves the retry half of Vault 68247 / shop/issues#73647 (Observe).
A 502/503/504 from the proxy in front of a Shopify API was classified as a terminal
client-errorinretryAwareRequestand thrown on the first attempt, so a single gateway blip killed the whole command. The issue was auto-reopened on event volume: 35 affected users in 7 days ongetThemesalone, plus a matching signature on the storeexecutepath (shop/issues#73646).This restores the retry work from the first push of #8329 (commit 2edc7d0, force-pushed away when that PR was narrowed to reporting only), rebased onto current
main, plus one additional fix described below.Retry gateway statuses, gated on idempotency
graphqlRequest/graphqlRequestDocmark a document idempotent when it holds exactly onequeryoperation, so this path never resends a mutation. That gate is what makes retrying safe at all — a 502 can mean the upstream processed the request and only the response was lost, and 504 in particular means the gateway did forward the request to an origin that may have been mid-write. A malformed query is treated as non-idempotent rather than throwing, so the API keeps rejecting it exactly where it does today.Gateway errors get their own budget of 3, not the default 10: unlike a 429 they carry no instruction about when to come back, and an upstream that is already failing should not be hit ten more times on its way down. It's a separate counter rather than an override of
limitRetriesTo, so an explicit caller cap still composes.HTTP 500 is deliberately excluded — the API itself answered and failed, so it keeps its existing non-retryable, reportable behaviour, and the existing test asserting that is untouched.
Second fix: the network-level retry was matching mutation payload text
While verifying the above I found a pre-existing hole that undermines the guarantee.
runRequestWithNetworkLevelRetrydoes see the thrownClientErrorand tests it withisTransientNetworkError, which searcheserror.message— and aClientError's message embedsJSON.stringify({response, request}), so the request's own query and variables are part of the searched string:getThemesqueryThemeFilesUpsertwith plain LiquidThemeFilesUpsertwith an asset containingsetTimeoutA theme asset containing
setTimeoutmatches'timeout', so a 502 on theThemeFilesUpsertmutation retried purely because of the file's contents — risking duplicate file writes. AClientErrormeans a response came back, so it is never a connection-level failure and must not be message-matched; retryable statuses are now classified deliberately where idempotency is known. The regression test pins it: with the guard removed it fails, re-POSTing the mutation until the retry window expires (5s).This is scoped to the retry loop only —
isTransientNetworkErroritself is unchanged, soisNetworkErrorclassification inpublic/node/api/admin.tskeeps its current behaviour.Incidental latent fix
Hoisting the
Retry-Afterparse out of the throttling branch also fixes a bug on that path: the old inlineNumber.parseIntyieldsNaNfor the HTTP-date form of the header, and??does not treatNaNas absent, soNaNreachedsetTimeoutand fired immediately — turning a 429 with a date-formRetry-Afterinto 10 back-to-back retries with no delay. It's now normalised toundefinedso the default delay applies.Relationship to #8329
Complementary, no file overlap — #8329 changes
public/node/error.ts(stop reporting gateway errors as crashes), this changes the request layer. Either can land first. Once both are in, #8329's inlinedstatus === 502 || 503 || 504should collapse onto the sharedisGatewayErrorStatushelper added here, so the status list lives in one place.Testing
pnpm --filter @shopify/cli-kit vitest run src/private/node/api.test.ts src/public/node/api/graphql.test.ts— 52 passing, including 6 new retry tests (recovery, budget exhaustion,retry-after, non-idempotent, 500, payload-text regression) and 5 new idempotency-marking tests.@shopify/cli-kitsuite: 1876 passing. The 2 failures inhooks/deprecations.test.tsare pre-existing on cleanmain(hardcoded "December 31, 2025" expiry), unrelated to this change.pnpm nx run-many --all --target=type-check(10 projects),--target=lint(14 projects),node bin/run-knip-ci.js, andpnpm refresh-code-documentation(tree stays clean) all pass.getThemesquery from the Observe signature succeeds on the second attempt; an equivalent mutation is not retried.Known, not changed here
retryDelayMsFromHeaderstreatsRetry-Afteras milliseconds, but the header is defined in seconds — so the CLI waits 1000× too little. That's pre-existing on the 429 path (and pinned by an existing test asserting200→ 200ms), so correcting the unit would change existing rate-limit timing and seemed worth raising separately rather than smuggling in here.