fix(adapter-api): stop the feed reconnecting against a refusal that repeats - #200
Merged
Merged
Conversation
…epeats isFatalFeedError ended the reconnect loop only for an unrenewable credential (401) and an authorization refusal (403). Its doc comment already stated the general rule — a response that "will reject the next connection identically, so retrying only spins" — but the predicate implemented a narrower version of it. Every other client-faulting refusal was therefore treated as transient. A GET /changes answered 400 bad_request, which a malformed cursor, kind or include value earns, deserializes to StackQueryError; that is neither class, so subscribeChanges() reported it through onError and retried, saturating at RECONNECT_MAX_MS into an attempt roughly every 15 seconds, indefinitely, sending the same rejected request each time. onReset never fires on that path, so the application had nothing to reconcile from either — it just received the same error on a timer. The predicate now decides on the wire status, which keeps it correct as the error taxonomy grows: a 4xx ends the loop, a 5xx reconnects. The distinction matters most for timeout (503), the answer a server gives while shedding query load — treating that as fatal would turn a busy server into a permanently dead subscription, which is worse than the loop this fixes. Enumerating error classes instead would be one StackTimeoutError away from exactly that, so the status is the safer thing to read. Both directions are pinned by tests: a 400 stops the loop, a 503 does not. The rule is now in docs/spec/wire-format.md § Backpressure and reconnection, which described when a client reconnects but not when it gives up. The status table there already implied it — 503 is documented as "worth retrying — which is why this is not bad_request: that code tells a client its request was malformed and retrying won't help." Closes #199 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FH88yDv3Fk2RmLsU7DU4Rz
🦋 Changeset detectedLatest commit: ddeaaf4 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Merged
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.
Summary
Closes #199.
isFatalFeedErrorended the change feed's reconnect loop only for an unrenewable credential (401) and an authorization refusal (403). Its doc comment already stated the general rule — a response that "will reject the next connection identically, so retrying only spins" — but the predicate implemented a narrower version of it.Every other client-faulting refusal was therefore treated as transient. A
GET /changesanswered400 bad_request— which a malformed cursor,kindorincludevalue earns — deserializes toStackQueryError, which is neither class, sosubscribeChanges()reported it throughonErrorand retried.reconnectDelaysaturates atRECONNECT_MAX_MS, so it settled into an attempt roughly every 15 seconds, indefinitely, sending the same rejected request each time.onResetnever fires on that path, so the application had nothing to reconcile from either — it just received the same error on a timer.The predicate now decides on the wire status, which also keeps it correct as the error taxonomy grows:
StackErrorandWIRE_ERROR_STATUSwere both already imported in this file, so no new dependency.permissionmaps to 403, so the explicitStackPermissionErrorcase is subsumed and its import is gone.The 5xx carve-out is the load-bearing part.
WIRE_ERROR_STATUSputstimeoutat 503 andmigrationat 500, andtimeoutis the answer a server gives while shedding query load. Treating that as fatal would turn a busy server into a permanently dead subscription — worse than the loop this fixes. Enumerating error classes instead of reading the status would be oneStackTimeoutErroraway from exactly that, which is why the status is what the predicate reads.Spec
Yes —
docs/spec/wire-format.md§ Backpressure and reconnection gains one paragraph. It described when a client reconnects but never when it gives up, so the 401/403 behavior already shipped was undocumented too; this states the whole rule rather than just the new half.The status table in the same document already implied it, which is partly why this reads as a fix rather than a new policy:
Verification
pnpm run format:check,pnpm run lint,pnpm test,pnpm run build,pnpm run typecheck— all clean, in that order. 1320 tests across the workspace; adapter-api goes from 217 to 219.Both new tests were confirmed to fail against the predicate they pin, in opposite directions:
401 || 403predicate,stops reconnecting after a 4xx the reconnect would only repeatfails;err instanceof StackErrorpredicate with no 5xx carve-out,keeps reconnecting after a 503 the server may recover fromfails.Notes for reviewers
Found while reviewing the change feed in
haverstack/server(haverstack/server#94, haverstack/server#95). The server-side question was whether a charset-invalid resume cursor should stay a400or become aresetframe. Conclusion was to keep the400: it isn't a value any conformant server could have minted, so there is nothing to resynchronize from, and a400naming the bad value stays diagnosable where a silentresetwould let a client persist cursors wrongly and pay a full resync on every reconnect forever. That left this retry loop as the one real cost of the400, and it belongs here rather than being worked around by making the server quieter.Worth knowing how narrow the trigger is: this client guards its own cursors at both entry points —
opts.sinceisisValidSeq-checked at subscribe time, anddispatch()adopts a frame id onlyif (frame.id !== undefined && isValidSeq(frame.id))— so it will not produce a charset-invalidLast-Event-IDitself. What reaches the loop is an intermediary corrupting the header in transit, a caller that persisted a cursor and mangled it in its own storage, or a future server-side validation this client doesn't anticipate. The fix is cheap and the failure mode is bad, but nobody is hitting this daily.Note also the pre-existing asymmetry this doesn't change: on a first connection (
!settled) any error already rejectssubscribeChanges()correctly. Only the reconnect path spun.🤖 Generated with Claude Code
https://claude.ai/code/session_01FH88yDv3Fk2RmLsU7DU4Rz
Generated by Claude Code