Skip to content

fix(ratelimit): take the global and per-chat tokens together - #1393

Merged
yagop merged 3 commits into
masterfrom
fix/ratelimiter-take-both-tokens
Nov 9, 2026
Merged

yagop merged 3 commits into
masterfrom
fix/ratelimiter-take-both-tokens

Conversation

@yagop

@yagop yagop commented Nov 9, 2026 •

Copy link
Copy Markdown
Owner

Fixes #1390.

Problem

RateLimiter.acquire() took the global token, then waited on the chat's bucket. A request queued behind a busy chat spent its global token right away and was sent later, with no fresh check of the global rate. A chat backlog could then push sends over the global cap in short windows (the long-run average still can't exceed it), and it wasted the global burst on requests that wait for seconds.

The swap suggested in the issue (chat first, global last) moves the same problem to the per-chat cap. While the global bucket is contended, a second request for the same chat takes its chat token and waits too. When the global bucket frees up, both can go out within milliseconds, because take() is a wake-and-recheck loop, not a queue.

Fix

TokenBucket.takeAll(buckets, signal) waits max(waitMs) over all buckets, rechecks after waking, and takes one token from each only when all have one. acquire() passes it the global bucket and the chat bucket. take() is now takeAll([this]), so single-bucket behavior is unchanged.

  • Neither token is spent while a request waits on the other, so both limits are checked at send time.
  • An aborted wait spends no token from either bucket. The swap would have lost one chat token.
  • TokenBucket is exported, so TokenBucket.takeAll is new public API (CHANGELOG and doc/api.md updated). Nothing existing changes signature.

Tests

Two new cases in test/unit/ratelimiter.test.ts, both on the fake clock:

  • A request waiting on its chat holds no global token, and another chat gets it immediately. This fails on the old order.
  • A request waiting on the global cap holds no chat token, and an abort spends neither. This fails on the swapped order (checked locally).

npm run check passes (305 unit tests).

yagop added 2 commits November 9, 2026 21:17
acquire() took the global token, then waited on the chat bucket. A request
queued behind a busy chat spent its global token early and went out later
with no fresh global check, so a chat backlog could push sends over the
global cap. Swapping the order moves the same problem to the per-chat cap.

TokenBucket.takeAll waits until every bucket has a token and takes one from
each in the same step. Nothing is spent while a request waits, and an abort
spends nothing.

Fixes #1390

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new takeAll method checks duplicate bucket references before decrementing, allowing requests to exceed available tokens and drive the token count negative.

1 open issue
What changed in this PR

Addresses #1390 by acquiring global and per-chat rate-limit tokens together, preventing queued requests from spending one token while waiting for the other.

Changes:

  • Adds public TokenBucket.takeAll() and uses it for single-bucket and combined acquisition.
  • Adds regression tests for token preservation during waits and cancellation.
  • Updates the API reference and changelog.
File Description
test/​unit/​ratelimiter.test.ts Tests token preservation while either limit blocks acquisition.
src/​core/​ratelimiter.ts Implements combined token acquisition and integrates it into RateLimiter.
doc/​api.md Documents takeAll() and the revised rate-limiting behavior.
CHANGELOG.md Records the rate-limiting fix and new public method.

Review effort: Balanced

Comment thread src/core/ratelimiter.ts
* a fresh check of the first bucket's rate. Same recheck loop and abort
* guarantee as `take()`: nothing is consumed until all waits are in the past.
*/
static async takeAll(buckets: readonly TokenBucket[], signal?: AbortSignal): Promise<void> {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed in 4683eb3. takeAll now deduplicates with a Set before the check and decrement loops, so a repeated bucket is checked and charged once. RateLimiter itself never passes the same bucket twice (global and chat buckets are distinct objects), so this only affected direct callers of the public method. Added a test: takeAll([b, b]) on a 1-token bucket leaves it at 0, and a second call waits instead of driving it negative.

takeAll checks every bucket before decrementing any, so a repeated bucket passed its check twice on one token and went negative. Deduplicate with a Set. RateLimiter never passes duplicates; this guards the public method.
@yagop
yagop merged commit 3870a08 into master Nov 9, 2026
7 of 8 checks passed
@yagop yagop mentioned this pull request Nov 9, 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.

RateLimiter takes the global token before the per-chat wait, so backlogged chats bypass the global cap

2 participants