Rate Limiter
API Rate Limiting System
Design the classes of a rate limiter: interchangeable algorithms behind one interface, per-tier and global rules checked in order, a retry-after on every refusal, and correct counting under concurrency.
Overview
A rate limiter decides, for each incoming request, whether to let it through or refuse it because the caller has sent too many recently. It protects a service from one noisy client, a runaway script or a traffic spike. A refused HTTP request usually gets status 429 Too Many Requests, which RFC 6585 defines and which may carry a Retry-After header saying how long to wait.
In a low-level design interview the question is about the objects: one interface that every algorithm implements, a small rule list that says which key each limit applies to (user, tier, whole service), time passed in so tests are exact, and a lock that makes 'count and decide' one step. The interviewer also expects you to compare the algorithms and to say which one you would pick and why.
Scoping the problem
- What is the key: user, API key, IP, endpoint? — Decides the key function of each rule — User, plus a global cap
- Are there tiers (free, paid)? — Different limits for the same rule — Yes
- Should short bursts be allowed? — Token bucket allows them; a window may not — Yes, up to a set size
- How exact must the count be? — Exact logs cost memory; counters approximate — Approximate is fine
- One process or many servers? — Local state or a shared store — One process here; many is a follow-up
- What does a refused caller get? — Status and headers — 429 with Retry-After
Core entities
ByTier is itself an Algorithm that forwards to the one configured for the caller's tier, so a tiered rule looks like any other rule to the limiter.
The algorithms side by side
- Fixed window — Window number and a count — Twice the limit, across a window edge — The boundary burst below
- Sliding log — Every accepted timestamp in the window — The limit, in any window-length span — Memory grows with the limit
- Sliding counter — Two counts: this window and the last — About the limit; the estimate can be a little off either way — Assumes last window's requests were spread evenly
- Token bucket — Tokens and the time of the last refill — The capacity at once, then the refill rate — Two numbers to explain to users (rate and burst)
- Leaky bucket (not in the code) — A queue drained at a fixed rate — Nothing extra: output is smooth — Adds waiting instead of refusing
AWS API Gateway, for example, documents that it throttles with a token bucket, configured by a rate (tokens added per second) and a burst (the bucket's capacity).
The boundary burst
Take a limit of 3 per 10 seconds. A client sends 3 requests at 9.9 s and 3 more at 10.0 s. The demo runs this exact sequence against three algorithms.
- Fixed window — all allowed — all allowed — 10.0 s starts a new window with a fresh count: 6 requests in 0.1 s
- Sliding log — all allowed — refused — The three from 9.9 s are still within the last 10 s
- Sliding counter — all allowed — refused — Previous window weighs 1.0 at the start of the new one: estimate 3
The sliding counter's estimate is previous count × the share of the previous window still inside the last 10 seconds, plus the current count. At 15 s that share is one half, so the estimate is 3 × 0.5 = 1.5 and one more request fits (1.5 + 1 = 2.5 is at most 3), and the next does not (3.5).
Token bucket, step by step
Capacity 3, rate 1 token per second. Tokens are refilled lazily: on each call, add (now − last) × rate, capped at the capacity, then spend one if there is one. Nothing runs in the background.
- 0 — 3 (new key) — 0 — allow — 2
- 0 — 2 — 0 — allow — 1
- 0 — 1 — 0 — allow — 0
- 0 — 0 — 0 — refuse — 0 — (1 − 0) ÷ 1 = 1 s
- 0.5 — 0 — 0.5 — refuse — 0.5 — (1 − 0.5) ÷ 1 = 0.5 s
- 1.0 — 0.5 — 0.5 — allow — 0
Checking a request
Each algorithm reads the key's state, decides and writes it back inside with self._lock. Without the lock, two threads could both read 'one token left' and both spend it. The demo sends 80 requests from 8 threads at the same instant to a bucket of 20 and asserts exactly 20 pass.
What you will be able to do
- Put five algorithms behind one try_acquire interface and choose between them with a table.
- Show the fixed-window boundary burst and how a sliding log or counter prevents it.
- Trace a token bucket by hand, including the Retry-After value.
- Combine per-tier and global rules and explain what happens when a later rule refuses.
- Make each check atomic with a lock, and explain what changes when the state moves to a shared store.
Functional requirements
- try_acquire(key, now) returns a Decision: allowed, remaining, and retry_after in seconds when refused
- Algorithms: token bucket, fixed window, sliding log and sliding counter, all interchangeable
- Rules pair a key function (user, tier, global) with an algorithm; RateLimiter.check runs them in order and reports which rule refused
- Tiers: ByTier routes each (tier, id) key to that tier's algorithm
- Idle token buckets are dropped once they have refilled to full
Non-functional requirements
- O(1) time and memory per key for token bucket, fixed window and sliding counter; O(limit) for the sliding log
- Concurrent requests for the same key are counted exactly: check and update happen under one lock
- A new algorithm is a new Algorithm subclass; RateLimiter and the rules do not change
- Time is passed in, so every decision in the tests is exact
Interview questions
- Which algorithm would you pick for a public API, and why?
- The per-user rule allowed a request and then the global rule refused it. Was the user charged?
- How does this change with ten API servers behind a load balancer?
- What should the client see when it is refused?
Sign up free to read the full topic
More Low-Level Design topics
Start free on Cariara · Coding problems · System design studio