LLM Admission Gateway
Admission control · Redis · Lua · TypeScript

Rate limiting stops working when a request’s cost is unknown.

A conventional limiter charges one unit per request and decides once, on arrival. For LLM inference neither half holds: cost is tokens, varying by three orders of magnitude between a one-line completion and a 200k-token context — and the larger half of that cost, the output, is not known until generation stops.

So admission cannot be one decision. It is a protocol: hold budget against an unknown, reconcile when it becomes known, and reclaim when the holder never comes back. The third part is what makes it a distributed systems problem rather than bookkeeping.
The protocol

Three phases, one atomic script each

Refill, compare, deduct and lease happen inside a single Lua script, so N gateway replicas cannot over-admit by a factor of N. Redis runs a script to completion, which serialises the whole decision on the shard without any lock the gateway has to hold, renew, or lose.

01 — RESERVE

Hold the unknown

Deduct an estimate, write a lease with a deadline, return a reservation id. Under worst_case the estimate is the declared ceiling; under adaptive it is an EWMA of what this model has actually been producing. The service has a third mode, quantile, which holds the q-th quantile of a rolling histogram instead — so the knob states the intended overrun rate rather than leaving it to a safety factor.

02 — COMMIT

Reconcile

Generation finished, so the real cost is known. Refund the difference, or charge the overrun. Idempotent, because this call happens right after a slow generation — exactly when clients time out and retry.

03 — EXPIRE

Reclaim

A client that dies between the two calls would otherwise burn its tenant’s budget forever, with every individual decision still correct and nothing to alert on. Expired leases are swept in bounded batches, on the admission path.

Live instrument

Drive the protocol

Send requests and watch the budget split into free, held and spent. Complete one to see the hold reconciled. Abandon one to simulate a client crash — the budget stays held until the lease expires, and the next admission reclaims it.

tenant acme · model llama-70b
t+0.0s
Balance — what the next request is judged against 0 / 0
Held by in-flight reservations — returned at commit 0
balance held overrun debt both bars share the same capacity axis; they are not additive — a hold was deducted once, and the bucket refills independently of it
inflight
0
slots
0/0
queue
0/0
pred. wait
0ms
ewma out
—
reclaimed
0
Policy
What this is. A faithful browser reimplementation of the admission logic in src/llm/admission-scripts.ts — the same constraint ordering, fractional token-bucket refill, bounded inline lease sweep, idempotent settlement and EWMA estimator. It is not the service: there is no Redis, no PostgreSQL and no network here, so nothing on this page is a latency measurement. Real numbers are below, and they come from committed artifacts. Two deliberate differences. The lease default is 8 s rather than 120 s and the budget is scaled down, both so that expiry and refill are observable at human speed. And every request here is interactive at the default batchFloor = 0, where the three priority-floor conditions in the real script reduce exactly to the hard gates above them and are therefore unreachable — so the ladder below is the production ladder for this configuration, not a simplification of it. The service has a third reservation mode, quantile, which this page does not offer: it needs a histogram with enough settled completions behind it to mean anything, which is more traffic than a page you click through produces. An earlier version of this page also noted that the simulator applied refill on commit while the production Lua did not — that defect has since been fixed, with a regression test that fails against the old script, so the two now agree.
Measured, not projected

What one replica actually does

Open-loop load against absolute arrival deadlines, full reserve + commit cycle per request, budgets set far above the offered rate so this measures the cost of deciding rather than the cost of refusing. Raw output for every row is committed under bench/results/.

offered cycles/sachievedp50 msp95 ms p99 msp99 rangespreadgateway CPURedis CPU
2502500.410.510.730.60–0.851.4×15.0%5.0%
5005000.380.500.610.57–0.981.7×21.9%8.7%
100010000.410.570.730.68–0.741.1×33.7%15.3%
150015000.450.681.220.87–1.531.8×45.4%21.4%
200020000.590.851.471.20–2.081.7×55.8%27.5%
250024990.661.082.411.64–13.368.1×66.7%33.1%
300029990.701.836.932.45–12.425.1×75.7%37.0%

Median of five sweeps, with the range and the max/min spread beside it. 32 cores, Node 26.7.0, Redis 8.10.1 and PostgreSQL 18.6 over loopback; host load average 1.3–3.4, the quietest of the three runs, recorded in bench/results/repeat/load.txt. Reproduce with ./bench/repeat.sh 5 20. The CPU columns are from a single sweep rather than the median of five, because CPU repeats closely between runs where the tail does not, and the harness writes them into each per-rate artifact, so they are sourced like every other number here.

Those two columns used to be wrong. The harness computed ticks / CLK_TCK / wall * 100 in bc, which truncates after every division — so at a 23-second run the answer was quantised to steps of ten and each step rounded down. The published column read 10/20/30/40/50/60/70 at seven consecutive rates, which looks like a textbook linear fit and was an artefact of the arithmetic, and Redis read 0% while using 5% of a core. Multiplying before dividing, once, gives the figures above. The conclusion is unchanged — linear in offered rate, Redis well under half a core — and the numbers are five to ten points higher.

The capacity claim is 1,000 cycles/s, not 3,000. The service serves 2,999 of 3,000 offered, and quoting that would look better. The medians are steady — no p50 has moved by more than 0.04 ms across three runs, through two changes to the Lua — but the spread is not, and that is the finding. Three runs now disagree about which rates are noisy: widest at 2,500 (10.1×), then at 1,500 (14.3×), then at 2,500 again (8.1×). The 1,500 outlier, left in the range at the time rather than dropped, did not recur. A quantity that reorders itself like that between runs of the same binary is a property of the host, not of the rate. What reproduces is the left of the table: 1,000 cycles/s came back at 1.1× spread on the quietest host of the three, the tightest point measured, with p99 0.73 ms.

What the cluster routing hop costs

offered cycles/sp50 singlep50 clusterp95 single p95 clustergw CPU singlegw clusterredis singleredis cluster
2500.410.440.510.5315.0%15.9%5.0%9.0%
5000.380.410.500.5221.9%23.0%8.7%16.0%
10000.410.430.570.6133.7%36.7%15.3%28.4%
15000.450.510.680.7645.4%50.3%21.4%39.8%
20000.590.600.850.9255.8%62.1%27.5%50.8%
25000.660.651.081.2166.7%71.5%33.1%59.0%
30000.700.581.831.5875.7%84.6%37.0%70.3%

Three masters, three replicas, 24 tenants — and the tenant count is the experiment. Every key of one pair shares a hash tag, so one tenant is one slot is one master; a cluster run driven by a single tenant measures one shard and reports a reassuring number for the wrong reason. The harness refuses to run if the tenants share a master. p50 moves by 0.02–0.06 ms, because ioredis caches the slot map and steady-state routing is a CRC16 and a table lookup rather than a round trip. p99 is deliberately absent: three runs have shown the tail to be a property of this host, so comparing one cluster sweep against a five-sweep median would be comparing noise and calling it a routing cost.

Redis's 1.9× is replication rather than sharding. Three masters with no replicas use 5.4% of a core at 250 cycles/s against single-node's 5.0% — sharding splits the work rather than adding to it — and adding three replicas takes it to 9.0%, about what applying the same write stream twice costs. Gossip is not it: six idle nodes use 0.9% of one core between them. Not measured: failover. Everything here ran against a healthy cluster.

Holding the ceiling strands most of a budget

modeadmittedmean heldunusedoverruntarget
worst_case75534172.5%0%0%
adaptive231173412.9%12.6%—
quantile212188919.8%6.1%10%

400 requests arriving together against 400,000 tokens/minute. Both estimates admit about 3× as much of the burst as reserving the ceiling. quantile holds 9% more per request than adaptive, admits 8% fewer, and halves the overrun rate. Overruns are charged at commit, so they are paid for rather than escaping the budget.

The target column is the point. A safety factor of 1.5 cannot be wrong, because it never claimed anything; q = 0.90 asks for a 10% overrun rate and can be held to it. It is not: the run delivered 6.1%, undershooting for two reasons in the design rather than the data — the histogram holds the upper edge of its bucket, up to 19% above the quantile, and the estimate comes from a finite recent window. Interpretable and directionally right; not calibrated.

assumed output medianunusedworstadaptivequantileadapt ovrquant ovr
9074.3%7526625512.8%6.7%
18072.0%7523021212.6%6.1%
40066.2%7517414612.1%2.7%
90054.0%75112859.8%3.5%
200035.0%7574740%0%

The 72% is an input, not a finding — arithmetic on a log-normal chosen to resemble chat traffic, against a fixed 4096 ceiling. No production trace was available, so the result is reported as a sweep over the assumption it rests on. The last row is the honest reading: once callers declare close to what they use, both estimates collapse onto worst_case — the hold clamps to the declared ceiling — and the overrun risk is not worth carrying. The last column is the second one: quantile sits below its 10% target at every point, by a margin that widens as the distribution tightens.

worst_case admitting exactly 75 at every median is the self-consistency check: that mode reserves the declared ceiling and ignores what is generated, so its count cannot depend on the output distribution. An earlier version of this benchmark showed it varying from 42 down to 0 — which is how the calibration phase was found to be draining the budget before the measured burst began.

A batch flood takes the budget, unless it is told not to

floorclassofferedadmittedadmitted %
0interactive2219442.5%
0batch6362308548.5%
0.25interactive221221100.0%
0.25batch6361283444.6%

One tenant, one budget. Batch offers 3× what the budget sustains, interactive a tenth of it. Both phases replay the same drawn traffic. Interactive goes from 42.5% to 100%; batch pays 3.9 points for it, because the floor is a level rather than a rate — everything refilling above it is still batch's to take. Total work moves from 158.9 to 152.7 admissions/s.

flooradmitted/stokens charged/s% of refill
0158.9149,71799.8%
0.25152.7149,73899.8%

The last column is the check that anything was contended at all, and it is not decoration. The first version of this benchmark reported 100% for both classes under both floors — true, and meaningless. A per-minute bucket starts full, and at overload factor X it takes 60/(X−1) seconds to drain, whatever the size of the budget: 29 seconds here, against a 20-second phase. Each phase now drains to its own steady state — which for a floored class is the floor itself — before anything is recorded.

The run admits ~159 req/s against a size-blind estimate of 106 (budget ÷ mean request). Both are right: admission from a contended bucket is not size-blind, because a large request needs more budget present at the instant it arrives, so the admitted set skews small (mean 989 tokens charged against a 1,445-token offered mean). That is a property of a token bucket, not of the floor. Note that the floor removes it for interactive traffic: at 100% admission its mean charged is 1,446, its own offered mean. Large interactive requests stop being the ones that lose.

Decisions

Why it is built this way

Expiry is swept inline, not by a background job

A sweeper is the obvious design and the wrong one: another process to deploy, monitor and elect a leader for, returning budget on its own schedule instead of when someone needs it — and it introduces the one race the protocol most needs to avoid, refunding a hold at the moment its owner commits it. A bounded sweep at the top of the admit script has none of those properties and needs no coordination.

The balance may go negative

An overrun is charged as a negative refund and the tenant refills out of the hole. Clamping at zero would make tokens beyond the reservation free, turning underestimation into a strategy and making adaptive mode exploitable by design rather than merely imprecise.

Commit is idempotent; reserve is not

Commit is called by a client that just waited out a slow generation, which is exactly when its request times out and gets retried. A retried reserve, by contrast, is a genuinely new request for capacity — the first is released by its lease, and deduplicating it would need an idempotency key the caller has no reason to have.

The two failure modes differ

The generic limiter fails open; LLM admission fails closed. A limiter that returns 500 on a Redis blip has made its own availability the ceiling on everything it fronts. But unmetered traffic into a fixed accelerator pool does not degrade gracefully — it queues until the backend collapses and every in-flight request is lost, not just the excess.

Known limits