Skip to content

fix(gateway): stop reconnect storms — per-socket state, stability-keyed backoff, identify budget - #71

Open
christianlappin wants to merge 2 commits into
mvanhorn:mainfrom
christianlappin:fix/gateway-reconnect-storm
Open

fix(gateway): stop reconnect storms — per-socket state, stability-keyed backoff, identify budget#71
christianlappin wants to merge 2 commits into
mvanhorn:mainfrom
christianlappin:fix/gateway-reconnect-storm

Conversation

@christianlappin

Copy link
Copy Markdown
Contributor

Problem

Two related failure modes, one root cause:

  1. Reconnect storm. On 2026-07-22 a single deployment made 276,379 gateway connections in one day (baseline ~1,700). Discord force-reset the bot token after the identify limit was exceeded. Log signature: bursts of identical "Connecting to Discord Gateway" lines, 85× close code 4005 "Already authenticated", 1,107× 4008 "Rate limited".
  2. Never-stable baseline. Even in "healthy" months, every connection was closed by Discord with code 1000 (no reason) ~50 seconds after connect, around the clock — that's where the ~1,700/day baseline came from. Supporting evidence from months of logs: ~993k "Heartbeat ACK not received" warnings, ~89.7k RESUMED vs ~1.8k READY.

Root cause

connectGateway() kept ws, heartbeatInterval, and heartbeatAckTimeout as single closure variables shared by every socket generation, and the jittered first-heartbeat setTimeout in startHeartbeat() was never tracked:

  • After any op 7 / 1001 reconnect, the old socket's onclose still fired and its cleanup() 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).
  • Each stale onclose / ack-timeout scheduled its own untracked setTimeout(connect) — under stress these parallel loops multiplied exponentially (the storm).
  • Backoff was ineffective: consecutiveFailures reset on any READY, and storm sockets did reach READY before dying, so every reconnect ran at the 5s floor.
  • Storm reconnects identified with resume:false every time; Discord resets the token at 1000 identifies/24h.

Fix

  • Per-socket state (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.
  • Single pending reconnect timer — first scheduler wins; parallel loops are structurally impossible.
  • Stability-keyed exponential backoff: 5s doubling to a 15-min cap with 50–100% jitter; resets only after a connection stays READY/RESUMED for 60s (not on READY itself).
  • Close-code handling: 4008 → 30-min cooldown and backoff pinned to the cap; 4007/4009 → drop session, re-identify; fatal codes (4004, 4010–4014) → stop permanently.
  • Identify budget (500 per rolling 24h, half of Discord's token-reset threshold): when exhausted the gateway stops reconnecting, logs an error, and reports through plugin health via a new optional onPermanentFailure hook (wired to runtimeHealth in worker.ts) instead of burning the token.

Testing

  • 12 new regression tests in tests/gateway-reconnect.test.ts (fake WebSocket + 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.
  • Full suite: 488/488 passing on this branch; tsc build clean.
  • Deployed to the affected instance: single "Gateway ready" (generation 1), zero closes/reconnects/heartbeat warnings in the observation window — first time the connection survived past ~50s in months.

🤖 Generated with Claude Code

christianlappin and others added 2 commits July 22, 2026 13:54
…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>
@christianlappin

Copy link
Copy Markdown
Contributor Author

Pushed a follow-up commit after ~22h in production: one handshake got a WebSocket error event while still CONNECTING and no close event ever followed, so onclose-driven recovery never ran and the gateway hung silently. The new commit adds a 60s connect watchdog (socket must reach READY/RESUMED or it is torn down and retried with normal backoff) and treats onerror on a non-OPEN socket as terminal. Both paths route through the same single-pending-reconnect scheduler; two regression tests added.

🤖 Generated with Claude Code

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.

1 participant