Skip to content

Rate limiting: RPM token bucket + TPM reserve/settle

Modulegw.03 · build · Go · Pass 7 · 4 h
You buildgo/gateway/limit/limit.go: Limiter (New, Reserve), Reservation (Settle, Cancel, Status), RejectError, EstimateCost, PromptText, TokenizeCounter, SetHeaders, RetryAfterSeconds, Middleware
Contract429 rate_limit_error / rate_limit_exceeded, Retry-After, and the x-ratelimit-* headers in course/contracts/openapi/openai-subset.v1.yaml; rpm and tpm of KeyCreate in course/contracts/openapi/admin.v1.yaml
Testscourse/tests/go/gw_03/ (what they check: section 4); the concurrency test runs under the race detector
Needsgw.01 server skeleton (the chain and the Exchange), gw.02 API keys (the Principal and its limits) · reading: S-M07d queueing and Little’s law, the practice drill go/07 (a token bucket warm-up)
Used bygw.07’s gateway.Deps, which puts limit.Middleware in the chain’s Limiter slot; your composition root builds the Limiter and calls it
MilestoneMS-gateway
Optional depthTanenbaum, Computer Networks, the token bucket; the GCRA in the ATM Forum’s traffic management spec; Stripe’s Scaling your API with rate limiters (free)
  • A token bucket holds at most CC tokens and refills at CC per minute; a request takes tokens if they are there, otherwise it waits exactly as long as the refill needs. A full bucket allows a burst of one minute’s budget, never more (TestBurstThenRefill).
  • Requests per minute and tokens per minute are two buckets per key. Token cost is unknown when a request starts, so the limiter reserves an estimate (prompt tokens plus max_tokens) and settles the actual usage when the response ends: unused tokens come back, overruns are charged (TestHandExample, TestSettleChargesOverrun).
  • A 429 carries Retry-After in whole seconds rounded up; every authenticated response carries the five x-ratelimit-* headers (TestMiddleware429Headers).
  • Exactly one of Settle and Cancel counts; a request that failed with a 5xx before any usage is cancelled, so clients do not pay for our outages (TestCancelAndSettleOnce, TestMiddlewareCancelsOn5xx).
  • One mutex around every bucket decision: 1000 goroutines against a budget of 100 admit exactly 100 (TestConcurrentReserve).
Terminal window
ol start gw.03 # writes go/gateway/limit/limit.go with stub bodies
ol tests gw.03 # read the test catalog first
ol check gw.03 # runs the course tests with -race
ol check gw.03 --ref-deps # only if your gw.01 or gw.02 is not passing yet
ol diff gw.03 # after passing: your code against the reference

Then build limit.New(clock) and limit.TokenizeCounter{BaseURL: engineURL} in your composition root; until gw.07’s gateway.Deps assembles the chain, plug limit.Middleware(limiter, counter) into the Limiter slot yourself.


Your gateway now knows who is calling (gw.02), but every caller can still send as fast as its network allows. One tenant’s batch job at 50 requests per second fills the engine’s queue (L10.2), and every other tenant’s time to first token goes from milliseconds to seconds: the noisy-neighbor incident you will meet as drill ops.09. Counting requests is not enough for an LLM: one request with a 4000-token prompt and max_tokens: 2000 costs a hundred times a short chat turn. This module gives each key two budgets, requests per minute (RPM) and tokens per minute (TPM), enforced before the request reaches the cache or an engine, and tells the client exactly when to come back.

SymbolMeaningType
CCbucket capacity: the per-minute limit (rpm or tpm of the key)integer
L(t)L(t)the bucket’s level at time ttreal, may go negative after an overrun
ρ=C/60\rho = C / 60refill rate per secondreal
nnthe cost of a request in this bucketinteger
wwthe wait before nn tokens are availableseconds

The bucket starts full, L=CL = C. Between decisions it refills continuously and is capped: L(t2)=min⁡(C, L(t1)+ρ (t2−t1))L(t_2) = \min(C,\ L(t_1) + \rho\,(t_2 - t_1)). A request costing nn is admitted when L≥nL \ge n, and then L←L−nL \leftarrow L - n. Otherwise it must wait

w=n−Lρ=(n−L)⋅60C seconds,w = \frac{n - L}{\rho} = \frac{(n - L) \cdot 60}{C}\ \text{seconds},

which is the Retry-After the client receives (in whole seconds, rounded up). Two properties follow. A key idle for an hour can burst at most CC at once, because of the cap. Over any long window, the admitted rate approaches ρ\rho, which is the limit. Each key has its own pair of buckets, so keys never spend each other’s budget; a key with limit 0 has no bucket (unlimited, per admin.v1.yaml).

Why not a fixed window (“at most 60 per calendar minute”)? It admits 120 in two seconds across a minute boundary. Why not a sliding log of timestamps? It is exact but costs memory per request. The bucket is O(1) per key and smooth.

A request’s token cost is prompt tokens plus completion tokens, and the completion length is only known when the stream ends. So the stage:

  1. Estimates the cost: prompt tokens counted by the engine’s tokenizer (POST /v1/tokenize, engine tier) over the prompt text (the completions prompt, or the chat contents joined by newlines; the chat template adds a few tokens this leaves out), plus max_completion_tokens or max_tokens, or 256 when neither is set. If the tokenizer is unreachable, it falls back to ⌈bytes/4⌉\lceil \text{bytes} / 4 \rceil rather than failing the request.
  2. Reserves that estimate in both buckets (1 request, nn tokens), or rejects with 429.
  3. Runs the rest of the chain.
  4. Settles with the actual usage the proxy put on the Exchange (gw.04): the difference between reserved and actual goes back into the bucket (capped at CC) or, for an overrun, is taken from it, possibly below zero. A negative level is a debt the key’s next requests wait off.

A reservation is settled or cancelled once: if the code settles and then a deferred cleanup cancels, the second call must do nothing, or the key gets a refund it never earned. If the request failed with a 5xx and no usage (no engine had capacity), the stage cancels: request and tokens both come back.

On every authenticated response (openai-subset.v1.yaml):

HeaderValue
x-ratelimit-limit-requests / -tokensCC of each bucket
x-ratelimit-remaining-requests / -tokens⌊L⌋\lfloor L \rfloor after this request’s reservation, at least 0
x-ratelimit-reset-tokenstime until the token bucket is full again, as a Go duration (18s)

On a 429 the same headers plus Retry-After: ⌈w⌉ seconds. Rounding down would invite the client back before the budget exists (and a ww of 0.4 s would become Retry-After: 0, “retry now”), so the value is ⌈w⌉\lceil w \rceil and at least 1.

Key k has RPM 3 and TPM 1000; the clock starts at t=0t = 0.

  1. A request reserves 300 tokens (prompt 60, max_tokens 240). Request bucket 3→23 \to 2, token bucket 1000→7001000 \to 700. Headers: remaining requests 2, remaining tokens 700, reset-tokens =300/(1000/60)=18= 300 / (1000/60) = 18 s.
  2. It finishes having used 120 tokens. Settle refunds 300−120=180300 - 120 = 180: the token bucket is 700+180=880700 + 180 = 880.
  3. Still at t=0t = 0, a request needs 900 tokens: 880<900880 < 900, so w=(900−880)⋅60/1000=1.2w = (900 - 880) \cdot 60 / 1000 = 1.2 s. The answer is 429 with Retry-After: 2 and remaining tokens 880.
  4. At t=1.2t = 1.2 s the token bucket has refilled 1000⋅1.2/60=201000 \cdot 1.2 / 60 = 20 tokens to exactly 900; the request is admitted and leaves 0. The request bucket refilled 3⋅1.2/60=0.063 \cdot 1.2/60 = 0.06 to 2.06, and after this request holds 1.06: remaining requests 1.

This is TestHandExample.

package limit // import "tinyllm/gateway/limit"
type Cost struct{ Requests, Tokens int }
type Limits struct{ RPM, TPM int } // 0 = unlimited
type Status struct {
LimitRequests, RemainingRequests, LimitTokens, RemainingTokens int
ResetTokens time.Duration
}
type RejectError struct { RetryAfter time.Duration; Status Status }
type Reservation interface { Settle(actual Cost); Cancel(); Status() Status }
func New(clock server.Clock) *Limiter
func (l *Limiter) Reserve(ctx context.Context, key string, lim Limits, c Cost) (Reservation, error)
type Counter interface { CountPrompt(ctx context.Context, model string, body []byte) (int, error) }
type TokenizeCounter struct { BaseURL string; Client *http.Client } // the engine's /v1/tokenize
func PromptText(body []byte) string
func EstimateCost(ctx context.Context, counter Counter, req *server.Request) Cost
func SetHeaders(h http.Header, s Status)
func RetryAfterSeconds(d time.Duration) int
func Middleware(l *Limiter, counter Counter) server.Middleware

The catalog sketched Reserve(ctx, key, c); this one also takes the key’s Limits, because limits live on the key record (gw.02) and change when an admin edits the key, so the limiter holds buckets, not key metadata (DEVIATIONS B93-04). Time comes only from the Clock, so the tests drive refill with a fake clock.

TestKINDChecksWhy it matters downstream
TestHandExampleunitsection 3, every number: remaining counts, reset 18 s, the settle refund, RetryAfter 1.2 s and Retry-After 2, admission at 1.2 syou and the tests agree on the bucket
TestBurstThenRefillunit60 at once, the 61st waits 1 s, one per second after, 10 idle minutes bank only 60the burst limit in the load report
TestSettleChargesOverrununitreserve 100, use 700: the next 10 tokens wait 11 srequests without max_tokens cannot dodge TPM
TestCancelAndSettleOnceunitcancel returns everything; a second cancel and a cancel after settle do nothingno double refunds
TestZeroMeansUnlimitedboundarylimits of 0 never rejectthe admin API’s “0 = unlimited”
TestOversizeCostRejectedboundary1001 tokens against TPM 1000 is rejected even when the bucket is full; 1000 fitsone request cannot exceed a minute’s budget
TestKeysAreIndependentunitkey a exhausted, key b untouchedthe noisy-neighbor drill
TestConcurrentReserveproperty1000 goroutines, RPM 100: exactly 100 admitted, no racecorrectness under load
TestRetryAfterSecondsboundary0, 1 ms, 1.2 s, 59 s, 59.001 sclients come back when the budget exists
TestEstimateCostunitprompt + max_tokens; max_completion_tokens wins; 256 default; bytes / 4 when the tokenizer failsreservations close to the truth
TestPromptTextunitprompt, chat contents, embeddings inputthe text that is tokenized
TestTokenizeCounterunitposts {model, text} to /v1/tokenize and counts the idsthe production counter
TestMiddleware429Headersconformanceheaders on every 200; the third request at RPM 2 is 429 with Retry-After: 30 and the error shapeconformance case ratelimit.429
TestMiddlewareSettlesActualUsageunitthe next response’s remaining tokens reflect the actual usage, not the estimateTPM tracks real cost
TestMiddlewareCancelsOn5xxunitafter a 503 without usage the one-request budget is backour outages are free
TestNoPrincipalPassesThroughboundaryno principal: no headers, no limitthe stage is safe before gw.02 is plugged in
TestMiddlewareKeysByKeyIDunittwo keys of one tenant have separate budgetsa CI key cannot starve a developer’s
PitfallSymptomCaught by
1. refill without the capa key idle overnight sends a day’s budget at onceTestBurstThenRefill (mutant s01)
2. refilling per second instead of per minute, or rounding Retry-After downlimits 60 times too loose; clients retry into another 429TestHandExample, TestRetryAfterSeconds (mutants s02, s06)
3. settle mistakes: refunding the whole reservation, never charging an overrun, both settle and cancel counting, settling the estimateTPM drifts from real usage in either directionTestHandExample, TestSettleChargesOverrun, TestCancelAndSettleOnce, TestMiddlewareSettlesActualUsage (mutants s03, s04, s05, s11)
4. treating 0 as an empty budget, or charging requests that failed with a 5xxunlimited keys are locked out; clients pay for our outagesTestZeroMeansUnlimited, TestMiddlewareCancelsOn5xx (mutants s07, s10)
5. admitting a request larger than the limit because the bucket is fullone request takes more than a minute’s tokensTestOversizeCostRejected (mutant s15)
6. no lock around a decision, or one bucket per tenantunder load more requests are admitted than the limit; keys drain each otherTestConcurrentReserve, TestMiddlewareKeysByKeyID (mutants s08, s14)
7. headers only on 429clients cannot pace themselves before they are throttledTestMiddleware429Headers (mutant s09)
8. a bad estimate: 0 tokens when the tokenizer fails, max_tokens ignoredhuge requests pass the reservation and overdraw afterwardsTestEstimateCost (mutants s12, s13)
DirectionModuleHow it uses this
Backgw.01the Limiter slot of the chain; the usage the proxy records on the Exchange
Backgw.02buckets are keyed by Principal.KeyID with the key’s RPM and TPM
Forwardgw.07gateway.Deps calls limit.Middleware between policy and cache, so a cached answer still spends the key’s RPM
Forwardload.01{loadgen} --rate 50 against a low-tier key shows 429s at the configured rate
Forwardops.09the noisy-neighbor drill floods one tenant; your limits keep the others inside their SLO

load.01 and ops.09 reach this module over HTTP through your gateway; they are not code call sites, so they are not in the registry’s used_by.

Your pieceProduction equivalentWhat it addsWhere to look
per-process bucketsEnvoy global rate limit service, Redis GCRAone budget shared by every gateway replicaEnvoy global rate limiting (free), GCRA (free)
reserve and settlesaige’s shared Budgetrequest, token, and cost capacity reserved under one lock and settled oncesaige budget package
two buckets per keyOpenAI and Anthropic API rate limitsper-model and per-organization tiers, daily capsOpenAI rate limits (free)
429 and Retry-Afterclient-side backoff with jitterclients that spread their retries instead of synchronizingAWS: Exponential backoff and jitter (free)