fix(gateway): stop reconnect storms — per-socket state, stability-keyed backoff, identify budget - #71
Open
christianlappin wants to merge 2 commits into
Open
Conversation
…ed backoff, identify budget The 2026-07-22 incident (276,379 gateway connects in one day, Discord reset the bot token) and the months-long ~50s baseline close cycle (~1,700 connects/day, close 1000 no reason) shared one root cause: socket state was held in closure variables shared across overlapping connections. - ws/heartbeatInterval/heartbeatAckTimeout were single variables overwritten by each connect(); stale sockets' handlers stayed live, cleared the CURRENT socket's heartbeat timers (Discord then zombie-closed it with 1000 every ~heartbeat interval — log evidence: 993k "Heartbeat ACK not received" warnings, 89k RESUMEDs vs 1.8k READYs), and each stale onclose scheduled its own reconnect, multiplying loops exponentially under stress. - Backoff was toothless: 60s cap, and consecutiveFailures reset on ANY READY, so storm reconnects all ran at the 5s base delay. - Storms identified with resume:false every time; Discord resets the token at 1000 identifies/24h. Fixes: - Per-socket state object owns its WebSocket, heartbeat timers (including the previously untracked first-beat jitter timeout), and ack flag; handlers are detached on teardown and guarded by identity, so a superseded socket can never log, heartbeat, touch session state, or schedule reconnects. - Exactly one pending reconnect timer, ever. - True exponential backoff with 50-100% jitter: 5s doubling to a 15 min cap, reset only after a connection stays READY/RESUMED for 60s; close 4008 (rate limited) pins backoff to the cap and cools down 30 min. - Fatal close codes (4004, 4010-4014) stop reconnecting permanently. - Hard identify budget (500/24h rolling window); when exhausted the gateway stops and reports through plugin health (new onPermanentFailure option, wired to runtimeHealth in worker.ts) instead of burning the token. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ose event 2026-07-23, ~8h after deploying the reconnect-storm fix: generation 8 got a WebSocket error 11s into CONNECTING and no close event ever followed. Recovery was driven solely by onclose, so no reconnect was ever scheduled — the gateway sat silently dead for 8+ hours with the bot offline while the worker kept running jobs. Two defensive layers, both routed through the existing single-reconnect path: - Connect watchdog: a socket that has not reached READY/RESUMED within 60s is torn down and retried with normal backoff. Covers CONNECTING hangs, error- without-close handshakes, and a HELLO/READY that never arrives. - onerror on a non-OPEN socket is terminal: destroy the socket and schedule a reconnect immediately instead of waiting for a close event that may never fire. Established sockets still let onclose drive recovery (it carries the close code for fatal-code handling). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
Author
|
Pushed a follow-up commit after ~22h in production: one handshake got a WebSocket 🤖 Generated with Claude Code |
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.
Problem
Two related failure modes, one root cause:
Root cause
connectGateway()keptws,heartbeatInterval, andheartbeatAckTimeoutas single closure variables shared by every socket generation, and the jittered first-heartbeatsetTimeoutinstartHeartbeat()was never tracked:onclosestill fired and itscleanup()cleared the new socket's heartbeat timers; stale generations kept clobbering the live socket's heartbeat pipeline, so Discord zombie-closed it ~one heartbeat interval later (the ~50s cycle).onclose/ ack-timeout scheduled its own untrackedsetTimeout(connect)— under stress these parallel loops multiplied exponentially (the storm).consecutiveFailuresreset on any READY, and storm sockets did reach READY before dying, so every reconnect ran at the 5s floor.resume:falseevery time; Discord resets the token at 1000 identifies/24h.Fix
GatewaySocket): each connection owns its WebSocket, heartbeat timers (including the previously untracked first-beat jitter timeout), and awaiting-ack flag. Teardown detaches handlers, so a superseded socket can never log, heartbeat, touch session state, or schedule reconnects.onPermanentFailurehook (wired toruntimeHealthinworker.ts) instead of burning the token.Testing
tests/gateway-reconnect.test.ts(fakeWebSocket+ fake timers): loop-multiplication guards, single pending reconnect, heartbeat ownership across generations, backoff growth/cap/stability-reset, 4008 cooldown, fatal codes, identify budget, rolling-window policy.tscbuild clean.🤖 Generated with Claude Code