Per-tenant quota limiter.
A token bucket per tenant sized by plan, costs per operation, a shared cluster bucket on top, and a 429 that says why and when to retry.
One global limiter can't tell tenants apart: a tenant sending 900 of the cluster's 1,000 requests a second leaves everyone else fighting over the last 100 (Day 2, Chapter 9, Mistake 2). And one limit for every endpoint treats an export like a health check (Mistake 5). This lab builds the gateway limiter the lesson asks for:
- A token bucket per tenant, sized by the tenant's plan (section 3.2). A tenant with no plan set is on
"free", never unlimited: the lesson's "fail open" warning. - A cost per operation (
COSTSin the given code): a search spends 5 tokens, an export 25. - A shared cluster bucket on top, so the tenants together can't exceed what the database can take.
- A useful rejection: which limit said no, and when to retry (Mistake 4: no silent throttling).
Implement TenantLimiter(plans, costs, global_rate, global_burst, clock):
| Method | Does |
|---|---|
set_plan(tenant, plan) |
moves a tenant to a plan. An unknown plan name raises ValueError |
check(tenant, operation) |
returns a Decision (given) and spends tokens when allowed |
The Decision fields:
| Field | Value |
|---|---|
allowed |
True or False |
reason |
None when allowed; "tenant" when the tenant's bucket is short; "global" when the tenant has enough but the cluster bucket is short; "plan" when the cost is bigger than the plan's whole burst |
limit |
the plan's burst (feeds X-RateLimit-Limit) |
remaining |
the tenant's tokens after this decision, rounded down to an int (feeds X-RateLimit-Remaining) |
retry_after |
0.0 when allowed; seconds until both buckets hold the cost; None for "plan" (waiting won't help: upgrade) |
Rules the tests check:
- Both buckets refill lazily,
elapsed × ratecapped atburst, the same rule as the Week 1 token bucket. A new tenant and the cluster both start full. - All or nothing: a request is allowed only if the tenant bucket and the cluster bucket hold its cost. A rejected request spends nothing from either.
- Check the tenant first: if both are short, the reason is
"tenant". - An operation that isn't in
costscostscosts["default"]. set_plankeeps the tenant's current tokens (refilled up to now first), capped at the new plan's burst. An upgrade doesn't hand out a free burst; a downgrade takes the excess away.- Read time only through
clock.now().
In production both buckets live in Redis and one Lua script checks and spends them together (section 4.1's distributed limiter). If you spent the tenant's tokens and then found the cluster empty, every rejection would still drain the tenant's quota.
Write your code, then run 3 example tests and 11 hidden tests. Each run is limited to 8 seconds: an infinite loop gets stopped and Python restarts, your code stays.