Repository navigation
fix(ratelimit): take the global and per-chat tokens together - #1393
Conversation
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
There was a problem hiding this comment.
🟡 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
| * 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> { |
There was a problem hiding this comment.
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.

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)waitsmax(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 nowtakeAll([this]), so single-bucket behavior is unchanged.TokenBucketis exported, soTokenBucket.takeAllis new public API (CHANGELOG anddoc/api.mdupdated). Nothing existing changes signature.Tests
Two new cases in
test/unit/ratelimiter.test.ts, both on the fake clock:npm run checkpasses (305 unit tests).