Skip to content

Reject token-bucket requests that exceed the remaining tokens - #156

Open
eeshsaxena wants to merge 1 commit into
upstash:mainfrom
eeshsaxena:fix/token-bucket-oversized-rate
Open

Reject token-bucket requests that exceed the remaining tokens#156
eeshsaxena wants to merge 1 commit into
upstash:mainfrom
eeshsaxena:fix/token-bucket-oversized-rate

Conversation

@eeshsaxena

Copy link
Copy Markdown

The bug

The token bucket only rejects a request when the stored token count is exactly 0:

if tokens == 0 and incrementBy > 0 then
  return {-1, refilledAt + interval, effectiveLimit}
end

That is correct for the default rate of 1, but .limit(id, { rate }) lets a single call consume more than one token. When the remaining tokens are between 1 and rate - 1, the guard does not fire and the code falls through to:

local remaining = tokens - incrementBy   -- e.g. 2 - 5 = -3
redis.call("HSET", key, "refilledAt", refilledAt, "tokens", remaining)  -- bucket stored as -3

So:

  • A rejected request still consumes tokens. With 2 tokens left, rate: 5 returns success: false but leaves the bucket at -3 instead of 2. The two tokens the caller was entitled to are gone.
  • A single large rate locks the identifier out. rate: 1_000_000 against a near-empty bucket stores ~ -1e6. expireAt = ceil((limit - remaining) / refillRate) * interval is derived from that deficit, so the key is pinned with a very long TTL (a 10s/refill-1 bucket ends up with a ~115-day PEXPIRE), and the identifier stays blocked long after the rejected call because the bucket has to climb back from deep negative.

The existing refund when over limit test only exercises the tokens == 0 path (bucket exactly empty), so the partial-bucket case slipped through.

The fix

Reject before the decrement whenever a consuming request asks for more than is available:

if incrementBy > 0 and tokens < incrementBy then
  return {-1, refilledAt + interval, effectiveLimit}
end
  • For incrementBy == 1, tokens < 1 is exactly tokens == 0 (tokens are integers), so the default path is unchanged.
  • Exact-fit requests (tokens == incrementBy) still succeed.
  • Refunds (incrementBy < 0) are unaffected.

The EVALSHA hash for the script in hash.ts is recomputed to match the new source, and a regression test covers a rate larger than the remaining tokens (rejected, bucket left intact, remaining tokens still spendable).

Verifying

I ported the script's arithmetic to check the before/after (no Redis needed):

input before after
2 tokens, rate 5 stored -3, reported failure (tokens consumed) rejected, bucket stays 2
2 tokens, rate 1e6 stored -999998, ~115-day TTL rejected, bucket stays 2
1 token, rate 1 success, 0 success, 0 (unchanged)
0 tokens, rate 1 rejected, not deducted rejected, not deducted (unchanged)
10 tokens, rate 10 success, 0 success, 0 (exact fit still passes)

CJS/ESM builds pass; the added test follows the existing negative-rate.test.ts layout. (The repo's DTS build trips on a pre-existing analytics.ts @ts-expect-error unrelated to this change.)

The token bucket rejected only when the stored token count was exactly 0
(`tokens == 0 and incrementBy > 0`). That covers the default rate of 1, but a
larger rate consuming more than is available fell through: with 2 tokens left a
`rate: 5` request set tokens to -3, wrote it back, and reported failure. So a
rejected request still consumed tokens, and a single large rate drove the count
far negative, locking the identifier out long after the rejected call (the
PEXPIRE is derived from the deficit, e.g. a rate of 1e6 sets a ~115-day TTL).

Reject when incrementBy > 0 and tokens < incrementBy, before the decrement, so
the bucket is left untouched. For incrementBy == 1 this is identical to the old
tokens == 0 check (tokens are integers), and exact-fit requests still pass. The
script hash is recomputed to match, and a regression test covers a rate larger
than the remaining tokens.
@vercel

vercel Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

@eeshsaxena is attempting to deploy a commit to the Upstash Team on Vercel.

A member of the Team first needs to authorize it.

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