AAI Agentic Handbook
Problem 127 · Practical builds

Rate-Limited LLM Request Scheduler

HardPractical multi-stage build
01Problem

Build the scheduler that sits in front of an LLM provider. Requests carry a tenant, model, estimated tokens and a priority lane. Dispatch must respect a provider rate limit, per-tenant token quotas, priority with starvation protection, and track cost per tenant.

Stages — finish each before reading the next
1Rate limiter

submit(request) queues work; dispatch(now) returns the requests that may start, using a token bucket (rate per second, burst capacity). Time is passed in, never read from the wall clock.

2Per-tenant quotas

Each tenant has a token quota per fixed window. Reserve est_tokens at dispatch; skip (do not block on) requests whose tenant is over quota; reject at submit any request that could never fit. complete(id, input, output) reconciles the reservation with actual usage.

3Priority lanes

Lane 0 (interactive) is served before lane 1 (batch). A lower lane passed over starvation_limit times in a row while non-empty gets the next slot.

4Cost accounting

complete() prices actual input/output tokens per model in integer micro-USD and adds it to a per-tenant ledger, returning the request's cost.

02Key insight

Separate the three gates: the bucket decides whether anything may start, quotas decide which tenant may, and lane order decides who goes first. Reserve the estimate at dispatch and reconcile at completion, or one tenant can overrun by issuing many requests before any finish. Keep money in integers.

03Solution

Try it first.

Timedispatch O(k * q) for k dispatched and q queued in scanned lanes
SpaceO(q + tenants + in-flight)
tests.py · 12 passing
tests.pypython39 lines
prices = {"small": (1, 4), "large": (3, 15)}
ids = lambda rs: [r.id for r in rs]
# stage 1: rate limit (1 req/s, burst 2)
s = Scheduler(1.0, 2, {"a": 10_000}, 60, prices)
for i in range(3):
    s.submit(Request(f"r{i}", "a", "small", 10))
assert ids(s.dispatch(0.0)) == ["r0", "r1"]
assert s.dispatch(0.5) == []
assert ids(s.dispatch(1.0)) == ["r2"]
# stage 2: quotas; over-quota tenant does not block others in the same lane
s = Scheduler(100.0, 100, {"a": 100, "b": 100}, 60, prices)
s.submit(Request("a1", "a", "small", 60)); s.submit(Request("a2", "a", "small", 60))
s.submit(Request("b1", "b", "small", 60))
assert ids(s.dispatch(0.0)) == ["a1", "b1"]
s.complete("a1", 10, 10)            # actual 20 < reserved 60: refund 40
assert ids(s.dispatch(0.1)) == ["a2"]
s.submit(Request("a3", "a", "small", 60))
assert s.dispatch(1.0) == []        # a has 80 used in this window
assert ids(s.dispatch(60.0)) == ["a3"]  # new window
try:
    s.submit(Request("big", "a", "small", 101)); assert False
except ValueError:
    pass
# stage 3: priority lanes with starvation guard (limit 2)
s = Scheduler(1.0, 1, {"a": 10**6}, 60, prices, lanes=2, starvation_limit=2)
for i in range(4):
    s.submit(Request(f"hi{i}", "a", "small", 1, lane=0))
s.submit(Request("lo0", "a", "small", 1, lane=1))
order = [ids(s.dispatch(float(t)))[0] for t in range(5)]
assert order == ["hi0", "hi1", "lo0", "hi2", "hi3"]
# stage 4: cost accounting in integer micro-USD
s = Scheduler(10.0, 10, {"a": 10**6, "b": 10**6}, 60, prices)
s.submit(Request("x", "a", "large", 2000)); s.submit(Request("y", "b", "small", 2000))
s.submit(Request("z", "a", "small", 2000))
s.dispatch(0.0)
assert s.complete("x", 1000, 200) == 6000
assert s.complete("y", 500, 500) == 2500
assert s.complete("z", 100, 0) == 100
assert s.cost == {"a": 6100, "b": 2500}
04Staff-level follow-up

How does this scale past one process? Move the bucket and quota counters to Redis with atomic Lua scripts (or a central admission service), honour provider 429 Retry-After by shrinking the bucket rate, and replace FIFO scanning with per-tenant sub-queues and weighted fair queuing so a noisy tenant can't fill a lane.