Skip to content

fix(transport): pace WebSocket messages by default and charge flushed posts - #96

Merged
joeblau merged 1 commit into
mainfrom
fix/issue-90
Aug 4, 2026
Merged

fix(transport): pace WebSocket messages by default and charge flushed posts#96
joeblau merged 1 commit into
mainfrom
fix/issue-90

Conversation

@joeblau

@joeblau joeblau commented Aug 4, 2026

Copy link
Copy Markdown

Summary

  • sharedWebSocketQuota() now enables the 2000/min token bucket by default ({ rateLimit: {} }: capacity 2000, refill 2000/min — the server's own budget); a directly constructed new WebSocketQuota() stays accounting-only and remains the documented opt-out via WebSocketTransportOptions.quota
  • New synchronous TokenBucketRateLimiter.tryAcquire(weight) keeps acquireSend's load-bearing "returns undefined when no wait is needed" contract true with pacing on (FIFO never bypassed)
  • The dispatcher's open flush now debits the message budget for post entries exactly once; subscribes paid at acquireSend time and are never double-charged
  • Paced waits are lifecycle-aware: an already-aborted signal rejects before spending a token, and transport termination abandons the wait (caller signal composes via AbortSignal.any, caller-reason precedence preserved), so a closed transport can't leak tokens or head-of-line-block other transports sharing the quota
  • Invariants preserved: posts never await pacing (exchange-action wire order), keep-alive pings only debit, pacing runs before the request timeout is armed
  • docs/transports.md rewritten for the new default + opt-out; 12 new tests across _rateLimiter.test.ts / _quota.test.ts

Behavior change note

Default transports now pace subscribe/unsubscribe frames against the documented server budget instead of silently overrunning it — the reconnect-storm case this issue is about. Orders are never delayed. Opt-out: new WebSocketTransport({ quota: new WebSocketQuota() }).

Verification (3-agent agreement, 4 review rounds)

  • Claude (Opus/Fable, this session): APPROVE
  • Kimi K3 Max: APPROVE (ran full offline suite 1710/0 + tsc clean; verified FIFO safety, exactly-once flush charging, termination composition)
  • Codex gpt-5.6-sol @ xhigh: APPROVE (independently ran focused tests 39/39; its round-2/3 findings drove the abort-guard, termination-relay, and AbortSignal.any hardening)
  • Known pre-existing (NOT introduced here, reproducible on main without pacing): a subscribe queued while disconnected whose caller then aborts can still be flushed on reconnect as an untracked server subscription — manager-lifecycle gap, agreed by all reviewers as follow-up work
  • Integration: all four fix branches merged together pass bun run check + full offline suite, 0 fail

Fixes #90

🤖 Generated with Claude Code

… posts

The default WebSocket transport shipped with the 2000 msg/min per-IP
budget unenforced: sharedWebSocketQuota() constructed its per-network
instance without rateLimit, so acquireSend/chargeSend were no-ops and a
reconnect at the 1000-subscription cap re-sent every subscribe frame
instantly — half the minute's server budget in one burst, repeated by a
flapping socket until the server refused.

The shared quota is now created with pacing enabled ({ rateLimit: {} }:
capacity 2000, refilling 2000/minute, the server's own budget). To keep
acquireSend's load-bearing contract — undefined, never a resolved
promise, whenever no wait is needed — TokenBucketRateLimiter gains a
synchronous tryAcquire(weight) that deducts inline only when no waiter
is queued (the FIFO is never bypassed) and the bucket covers the cost;
acquireSend probes it before falling back to the queued acquire. Direct
new WebSocketQuota() construction stays accounting-only, which is the
documented opt-out via WebSocketTransportOptions.quota.

Also fixes a charging gap in the dispatcher: posts queued while
disconnected and flushed by the open handler never debited the message
budget, since only the send-immediately branch charged them. The flush
now debits post entries (numeric id) exactly once, where their frame
actually reaches the socket; subscription entries already paid in
acquireSend at request() time and are not double-charged.

Docs (quota/rate-limit JSDoc and docs/transports.md) updated from
"pacing is opt-in" to the new default, with the opt-out spelled out.

Fixes #90

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@joeblau
joeblau merged commit 40c8bef into main Aug 4, 2026
3 of 4 checks passed
@joeblau joeblau mentioned this pull request Aug 4, 2026
joeblau added a commit that referenced this pull request Aug 4, 2026
Four fixes, each cross-verified to agreement by three independent
reviewers (Claude, Kimi K3 Max, Codex gpt-5.6-sol xhigh):

- #89 subscription failures notify every subscriber + failureSignal (#93)
- #90 WebSocket message pacing on by default, flush charging fixed (#96)
- #91 200-OK { type: "error" } envelopes throw HttpRequestError (#94)
- #92 wallet shape detection by member presence, not arity (#95)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@joeblau
joeblau deleted the fix/issue-90 branch August 4, 2026 05:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Default WebSocket quota ships with message pacing disabled — reconnect storms can trip the 2000 msg/min server budget

1 participant