fix(transport): pace WebSocket messages by default and charge flushed posts - #96
Merged
Conversation
… 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>
Merged
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>
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
sharedWebSocketQuota()now enables the 2000/min token bucket by default ({ rateLimit: {} }: capacity 2000, refill 2000/min — the server's own budget); a directly constructednew WebSocketQuota()stays accounting-only and remains the documented opt-out viaWebSocketTransportOptions.quotaTokenBucketRateLimiter.tryAcquire(weight)keepsacquireSend's load-bearing "returnsundefinedwhen no wait is needed" contract true with pacing on (FIFO never bypassed)openflush now debits the message budget forpostentries exactly once; subscribes paid atacquireSendtime and are never double-chargedAbortSignal.any, caller-reason precedence preserved), so a closed transport can't leak tokens or head-of-line-block other transports sharing the quota_rateLimiter.test.ts/_quota.test.tsBehavior change note
Default transports now pace
subscribe/unsubscribeframes 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)
AbortSignal.anyhardening)mainwithout 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 workbun run check+ full offline suite, 0 failFixes #90
🤖 Generated with Claude Code