Repository navigation
fix(api): replay a gateway error only for reads, so a write is never sent twice - #1891
dawsontoth wants to merge 1 commit into
Conversation
…sent twice Every instance client replayed a 502/503/504 up to three times whatever the request, and none of those statuses means the request went unapplied: a proxy answers for an upstream that may already have it, and Harper itself answers 503 after handing an operation to a worker that exits or overruns. So an insert could store its records twice, a drop could succeed and then report "does not exist", and a deploy or restart could run again. The retry now replays only what the restart gate already treats as a read — a GET, or a body whose operation is a read by Harper's naming. That predicate moves out of the gate into isReadOperation.ts as isReadRequest, so the two share one rule. A write is sent once and its first 5xx is final. skipGatewayRetry stays for the operations console, whose component operations can carry a read-looking name. Closes #1879 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request refactors the gateway retry mechanism to ensure that only read requests (GET requests or POST requests with read operations) are replayed, and integrates this unified check into the restart gate. The reviewer feedback suggests making the isReadRequest utility more robust by defaulting undefined methods to 'get' and restricting operation-based read checks specifically to 'post' requests, as well as adding corresponding test cases to verify these edge cases.
| export function isReadRequest({ method, data }: { method?: unknown; data?: unknown }): boolean { | ||
| return (typeof method === 'string' && method.toLowerCase() === 'get') || isReadOperation(operationOf(data)); | ||
| } |
There was a problem hiding this comment.
The current implementation of isReadRequest has two potential issues:
- If
methodis omitted/undefined, it will not default to'get', even though Axios defaults to'get'for requests. This would cause default GET requests to be incorrectly classified as writes. - If the HTTP method is a write/mutation method other than
POST(such asDELETE,PUT, orPATCH) but the request body happens to contain anoperationfield matching a read pattern, it will be incorrectly classified as a read request.
We can make this more robust by defaulting method to 'get' when it is undefined, and explicitly restricting the operation-based read check to POST requests.
| export function isReadRequest({ method, data }: { method?: unknown; data?: unknown }): boolean { | |
| return (typeof method === 'string' && method.toLowerCase() === 'get') || isReadOperation(operationOf(data)); | |
| } | |
| export function isReadRequest({ method, data }: { method?: unknown; data?: unknown }): boolean { | |
| const normalizedMethod = method === undefined ? 'get' : typeof method === 'string' ? method.toLowerCase() : ''; | |
| if (normalizedMethod === 'get') { | |
| return true; | |
| } | |
| if (normalizedMethod === 'post') { | |
| return isReadOperation(operationOf(data)); | |
| } | |
| return false; | |
| } |
| it.each([ | ||
| ['a GET', { method: 'get' }], | ||
| ['an upper-case GET', { method: 'GET' }], | ||
| ['a read body', { method: 'post', data: { operation: 'describe_all' } }], | ||
| ['a read body axios already serialized', { method: 'post', data: JSON.stringify({ operation: 'get_job' }) }], | ||
| ])('reads %s as a read', (_, config) => { |
There was a problem hiding this comment.
Add a test case to verify that isReadRequest correctly defaults to 'get' when the method property is omitted/undefined.
| it.each([ | |
| ['a GET', { method: 'get' }], | |
| ['an upper-case GET', { method: 'GET' }], | |
| ['a read body', { method: 'post', data: { operation: 'describe_all' } }], | |
| ['a read body axios already serialized', { method: 'post', data: JSON.stringify({ operation: 'get_job' }) }], | |
| ])('reads %s as a read', (_, config) => { | |
| it.each([ | |
| ['a GET', { method: 'get' }], | |
| ['an upper-case GET', { method: 'GET' }], | |
| ['an omitted method (defaults to GET)', {}], | |
| ['a read body', { method: 'post', data: { operation: 'describe_all' } }], | |
| ['a read body axios already serialized', { method: 'post', data: JSON.stringify({ operation: 'get_job' }) }], | |
| ])('reads %s as a read', (_, config) => { |
| it.each([ | ||
| ['a write body', { method: 'post', data: { operation: 'insert' } }], | ||
| ['a serialized write body', { method: 'post', data: JSON.stringify({ operation: 'drop_table' }) }], | ||
| ['a body that is not JSON', { method: 'post', data: 'describe_all' }], | ||
| ['a JSON null body', { method: 'post', data: 'null' }], | ||
| ['no body', { method: 'post' }], | ||
| ['a DELETE', { method: 'delete' }], | ||
| ['a method that is not a string', { method: 42 }], | ||
| ])('treats %s as able to change something', (_, config) => { |
There was a problem hiding this comment.
Add a test case to verify that a non-POST/GET request (like DELETE) with a body containing a read operation is still correctly treated as a write request.
| it.each([ | |
| ['a write body', { method: 'post', data: { operation: 'insert' } }], | |
| ['a serialized write body', { method: 'post', data: JSON.stringify({ operation: 'drop_table' }) }], | |
| ['a body that is not JSON', { method: 'post', data: 'describe_all' }], | |
| ['a JSON null body', { method: 'post', data: 'null' }], | |
| ['no body', { method: 'post' }], | |
| ['a DELETE', { method: 'delete' }], | |
| ['a method that is not a string', { method: 42 }], | |
| ])('treats %s as able to change something', (_, config) => { | |
| it.each([ | |
| ['a write body', { method: 'post', data: { operation: 'insert' } }], | |
| ['a serialized write body', { method: 'post', data: JSON.stringify({ operation: 'drop_table' }) }], | |
| ['a body that is not JSON', { method: 'post', data: 'describe_all' }], | |
| ['a JSON null body', { method: 'post', data: 'null' }], | |
| ['no body', { method: 'post' }], | |
| ['a DELETE', { method: 'delete' }], | |
| ['a DELETE with read body', { method: 'delete', data: { operation: 'describe_all' } }], | |
| ['a method that is not a string', { method: 42 }], | |
| ])('treats %s as able to change something', (_, config) => { |
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
⊙ Problem
Every instance client replayed a 502, 503 or 504 up to three times (5s, 10s, 20s apart), whatever the request. None of those statuses means the request went unapplied, so a write whose answer a gateway lost could run again: an
insertstoring its records twice, adrop_tablethat worked and then reported "does not exist", a seconddeploy_componentorrestart. Closes #1879.💡 Solution
The gateway retry now replays only requests the restart gate already treats as reads: a GET, or an operations-API body whose
operationis a read by Harper's naming. A write is sent once, and its first 5xx reaches the caller the same way the last failed retry used to. The predicate moves out of the restart gate intoisReadOperation.tsasisReadRequest, so "safe to send late" and "safe to send twice" are one rule with one list. Like the gate, it parses a body that axios has already serialized. That matters here: by the time a response fails, axios 1.20.0 has replacedconfig.datawith its JSON string (lib/core/dispatchRequest.js:46). Anything it does not recognize counts as a write.skipGatewayRetrystays, and the operations console still sets it, because a component can register aget_…operation that writes.Callers that lose the replay, checked one by one:
restart/restart_service): a 502/504 now fails the click straight away instead of sending a second restart 5s later. The tracker is still released infinally. Theuser_infoprobes inwaitUntilAnsweringAfterRestartare reads and keep their retry.set_configuration: theuser_infoprobe still retries. A 5xx onset_configurationorrestart_servicecounts that member as failed, and the loop moves on to the existing "Only N of M restarted" toast. No member restarts twice.fetchand is unaffected. A 504 on the buffereddeploy_componentnow shows "Failed to import" instead of deploying a second time.create_authentication_tokens,refresh_operation_token): neither is a read by name, on purpose (see the restart gate). A gateway status on the route guard's mint now sends the user to sign-in at once instead of holding navigation for up to 35s. A gateway status on a refresh falls back to re-minting through the proxy at once.get_job,get_status,get_deployment,get_analytics,cluster_status,GET /health,GET /api/openapi/rest): reads, so nothing changes.⚖️ Alternatives
skipGatewayRetryon every write call site. Rejected: the replay stays the default, there are about 40 distinct write operations, and any new write that forgets the flag brings the bug back.user_info, the rolling-config probes, the restart probes) have no other retry.isBuiltInRead(naming plus the role-editor catalog). Not needed: only the console sends operations Studio did not write itself, and it opts out entirely. Pulling the catalog intointegrations/apiwould also make the integration layer depend on a feature.The planning review (codex) returned
Framing-Verdict: chosen-approach-sound.🔧 Changes
src/integrations/api/retryGatewayErrors.ts: the status check now requiresisReadRequest(config). The doc comments say why a gateway status is not proof that a request went unapplied, and whatskipGatewayRetryis still for.src/integrations/api/isReadOperation.ts: addsisReadRequest, the gate's formeroperationOf+isSafeToDelay, unchanged except that a non-stringmethodreads as not-GET instead of throwing inside the interceptor.src/lib/restart/restartGate.ts: callsisReadRequest; the gate's behavior is unchanged.DESIGN.md: a new "Instance clients replay only reads" note, and the console's "At most one send" bullet now gives the reason that still holds.src/features/auth/describeAuthFailure.tsandsrc/integrations/api/sse/resolveInstanceConnection.tsdescribed the old retry; both now describe the new one.Filed while tracing callers: #1889. The live deploy's fallback sends
deploy_componentagain after a gateway status or a dropped connection, which is the same double send throughfetch, outside this interceptor. #1890. A write's gateway status still reads as a plain failure, which invites a manual retry. This PR stops the automatic replays and leaves that copy as it was. Residual, not filed: a read's retry on a proxied or cookie client re-sends with whatever credentials are current after the 5s–35s wait. Direct-connect clients already refuse that through the connection-generation stamp. This PR narrows that exposure to reads; it does not change it.✅ Verification
src/integrations/api/retryGatewayErrors.integration.test.tsbuilds a realgetInstanceClientclient whose adapter rejects with anAxiosErrorcarrying 502/503/504, then counts adapter calls. A write (insert,drop_table,deploy_component,set_configuration,restart,create_authentication_tokens) is sent once. A read (describe_all) is sent 4 times, sleeping 5s/10s/20s. A recoveringget_jobresolves on its replay, a GET replays, and a read withskipGatewayRetryis sent once.aaca36e38(feat(instance): add an Operations console for sending any operations-API request #1882's head) the same file fails 8 of 14 tests, all of them write cases, with the bug's signature:expected [ …(4) ] to deeply equal [ { method: 'post', … } ].&& isReadRequest(config)turns 13 tests red. Ignoring a serialized body turns 9 red, among them the end-to-end read replays and the restart gate's existing serialized-body test. Dropping the GET branch turns 4 red. Every mutation was reverted.retryGatewayErrors.test.ts(the retry tests now use a serialized read; new write cases cover object, serialized, token-mint, non-JSON and missing bodies across all three statuses) andisReadOperation.test.ts(isReadRequest).vitest runpassed 402 files / 3,791 tests.npx tsc -bexit 0,npx oxlint --format stylish .exit 0,npx dprint checkexit 0.JSON.parseof a large failed write" cost runs only on a gateway status, and it is the same order of work as theJSON.stringifyaxios already did to send that body. The Cursor legs failedno-receipt(the 1Password SSH agent refusedgit fetch), and the Harper domain leg failedauth(expired OAuth session), so no adjudication ran. That is an infrastructure gap, not an open finding.🤖 Generated with Claude Code (Claude Opus 5.5); posted via @dawsontoth.
Related PRs: #1882 overlaps (stack base — this PR builds on its isReadOperation and skipGatewayRetry and must land after it)
Complexity: medium
Review-Coverage: authored=claude; ran=codex,gemini; blocked=cursor-grok(no-receipt),cursor-composer(no-receipt),domain(auth); declined=cursor-kimi,cursor-muse; rounds=1; full=1 @ 3688575
Review-Attention: study ~11m (sensitive: describeAuthFailure.ts; raised: degraded review) @ 3688575