Skip to content

fix(adapter-api): stop the feed reconnecting against a refusal that repeats - #200

Merged
cuibonobo merged 1 commit into
mainfrom
claude/event-stream-security-review-pef2np
Aug 29, 2026
Merged

fix(adapter-api): stop the feed reconnecting against a refusal that repeats#200
cuibonobo merged 1 commit into
mainfrom
claude/event-stream-security-review-pef2np

Conversation

@cuibonobo

Copy link
Copy Markdown
Member

Summary

Closes #199.

isFatalFeedError ended 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 /changes answered 400 bad_request — which a malformed cursor, kind or include value earns — deserializes to StackQueryError, which is neither class, so subscribeChanges() reported it through onError and retried. reconnectDelay saturates at RECONNECT_MAX_MS, so it settled 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 also keeps it correct as the error taxonomy grows:

const isFatalFeedError = (err: unknown): boolean => {
  if (err instanceof APIAdapterAuthError) return true;
  if (err instanceof StackError) {
    const status = WIRE_ERROR_STATUS[err.code];
    return status >= 400 && status < 500;
  }
  return false;
};

StackError and WIRE_ERROR_STATUS were both already imported in this file, so no new dependency. permission maps to 403, so the explicit StackPermissionError case is subsumed and its import is gone.

The 5xx carve-out is the load-bearing part. WIRE_ERROR_STATUS puts timeout at 503 and migration at 500, and timeout is 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 one StackTimeoutError away 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:

503 … the request is worth retrying — which is why this is not bad_request: that code tells a client its request was malformed and retrying won't help

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:

  • against the original 401 || 403 predicate, stops reconnecting after a 4xx the reconnect would only repeat fails;
  • against a naive err instanceof StackError predicate with no 5xx carve-out, keeps reconnecting after a 503 the server may recover from fails.

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 a 400 or become a reset frame. Conclusion was to keep the 400: it isn't a value any conformant server could have minted, so there is nothing to resynchronize from, and a 400 naming the bad value stays diagnosable where a silent reset would 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 the 400, 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.since is isValidSeq-checked at subscribe time, and dispatch() adopts a frame id only if (frame.id !== undefined && isValidSeq(frame.id)) — so it will not produce a charset-invalid Last-Event-ID itself. 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 rejects subscribeChanges() correctly. Only the reconnect path spun.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FH88yDv3Fk2RmLsU7DU4Rz


Generated by Claude Code

…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-bot

changeset-bot Bot commented Aug 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ddeaaf4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@haverstack/adapter-api Patch

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

@cuibonobo
cuibonobo merged commit c8c9b75 into main Aug 29, 2026
5 checks passed
@cuibonobo
cuibonobo deleted the claude/event-stream-security-review-pef2np branch August 29, 2026 12:37
@github-actions github-actions Bot mentioned this pull request Aug 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Change-feed client reconnects forever on a 4xx the server will refuse identically

2 participants