Not the distributed one with Redis and a gateway fleet: the classes inside it. Three algorithms, one interface, a clock you control, and a registry that is safe when a thousand requests arrive for a key it has never seen.
The distributed rate limiter is a system design question (the course covers it in Week 1). The machine-coding version, a natural fit for payments and fintech teams where every API has a limit, is about the code inside one process: how you separate the algorithm from the rules, how you make time testable, and how you avoid the race where two threads create two buckets for the same client. If your answer starts with "we'd put Redis in front", you've answered the wrong question.
This lesson builds the classes end to end: the RateLimiter interface and its Decision, three algorithms, a rule table and a factory, a per-key registry with double-checked creation, and a demo driven by a fake clock.
The classes inside one process: a RateLimiter interface returning a Decision with retry_after, three algorithms (token bucket, fixed window, sliding log), an injected clock, rules turned into limiters by a factory, and a registry with one limiter per client and endpoint. A free user's burst of 7 gets 5 through, lazy refill returns 2.5 tokens in 0.5 s, the OTP window says retry in 19.5 s, and a fixed window lets 6 through at an edge where the sliding log allows 3.
How the round runs#
"Design a rate limiter library. Different clients have different limits on different endpoints: free users get 5 searches a second, pro users 50, and everyone gets 3 OTPs a minute. I want to be able to switch algorithms per rule. Write it so it runs, with a demo. Single process; don't worry about distributing it."
- 0–10 min — clarify: per what key, which algorithms, what the caller gets back, bursts, thread safety
- 10–20 min — the interface, the
Decision, theClock, theRule - 20–55 min — token bucket, fixed window, sliding log, each under 20 lines
- 55–70 min — the registry: rule lookup, a factory, one limiter per key, safe creation
- 70–80 min — a demo on a fake clock: bursts, refill, the boundary flaw, a thread hammer
- 80–90 min — follow-ups: sliding window counter, memory for idle keys, going distributed
Requirements and clarifying questions#
| You ask | They answer | What it changes |
|---|---|---|
| Limit per what? | Per client, per endpoint, with the rule picked by the client's tier. | Key = (client, endpoint); rule = (tier, endpoint) |
| What does the caller get back? | Allowed or not, and when to retry. | A Decision(allowed, remaining, retry_after), not a bare bool |
| Are bursts allowed? | For search, yes, up to the limit. For OTP, strictly 3 per minute. | Token bucket for search; a window for OTP |
| Can a request cost more than 1? | Yes, a bulk export costs 10. | try_acquire(cost) |
| Many threads? | Yes, it's inside a threaded web server. | A lock per limiter, and safe creation in the registry |
| What if no rule matches? | Use a default. | A default Rule in the registry |
Before the code: why inject a clock?
Every algorithm here depends on time. What goes wrong if the classes call time.time() directly, and what should they call instead?
One strong answer
Two things. Tests become slow and flaky: proving "the bucket refills 5 tokens a second" means sleeping, and proving "the window resets after 60 s" means waiting a minute. And time.time() can jump backwards (NTP corrections), which can produce negative elapsed time and a bucket that hands out tokens it doesn't have. Inject a Clock: MonotonicClock (wrapping time.monotonic(), which never goes backwards) in production, FakeClock in tests, where you advance(0.5) and assert.
The classes#
%% title: Rate limiter: the classes
classDiagram
class RateLimiter {
<<interface>>
+try_acquire(cost) Decision
}
class Decision {
+allowed bool
+remaining int
+retry_after float
}
class Clock {
<<interface>>
+now() float
}
class TokenBucket {
-tokens float
-last float
}
class FixedWindow {
-start float
-count int
}
class SlidingWindowLog {
-log deque
}
class Rule {
+algorithm str
+limit int
+window float
}
class RateLimiterRegistry {
-rules dict
-limiters dict
+check(client, tier, endpoint, cost) Decision
}
RateLimiter <|.. TokenBucket
RateLimiter <|.. FixedWindow
RateLimiter <|.. SlidingWindowLog
RateLimiter ..> Decision
TokenBucket --> Clock
FixedWindow --> Clock
SlidingWindowLog --> Clock
Clock <|.. MonotonicClock
Clock <|.. FakeClock
RateLimiterRegistry --> Rule
RateLimiterRegistry "1" *-- "many" RateLimiterOne limiter object guards one key. That keeps every algorithm simple (no dict of clients inside it) and lets each key have its own lock, so one busy client doesn't serialise everyone else. The registry is the only thing that knows about clients, tiers and endpoints.
The interfaces#
The clock, the decision the caller gets, and the interface: one method.
# rate_limiter.py, part 1: time, the decision, and the one interface every algorithm implements
from __future__ import annotations
import threading
import time
from abc import ABC, abstractmethod
from collections import deque
from collections.abc import Callable
from dataclasses import dataclass
class Clock(ABC):
@abstractmethod
def now(self) -> float: ... # seconds; must never go backwards
class MonotonicClock(Clock):
def now(self) -> float:
return time.monotonic()
class FakeClock(Clock):
def __init__(self, start: float = 0.0) -> None:
self.t = start
def now(self) -> float:
return self.t
def advance(self, seconds: float) -> None:
self.t += seconds
@dataclass(frozen=True)
class Decision:
allowed: bool
remaining: int
retry_after: float # seconds until a retry can succeed; 0 when allowed
class RateLimiter(ABC):
"""One limiter guards one key (a user, an API key, an IP)."""
@abstractmethod
def try_acquire(self, cost: int = 1) -> Decision: ...The three algorithms#
All three implement try_acquire(cost). Know the trade-off of each in one sentence; the interviewer will ask.
Token bucket
- Holds up to
capacitytokens, refilled atrateper second - Allows bursts up to capacity, then a steady rate
- O(1) memory: two numbers
- Good for: APIs where short bursts are fine
Fixed window
- Counts requests in aligned windows (
12:00:00–12:01:00) - Simplest; O(1) memory
- Up to 2× the limit across a window boundary
- Good for: coarse quotas (per day, per month)
Sliding window log
- Keeps the timestamp of every allowed request in the window
- Exact: never more than
limitin any window - O(limit) memory per key
- Good for: strict, low limits (3 OTPs a minute)
The token bucket refills lazily: nothing runs in the background. On each call it computes how much time has passed since the last call and adds elapsed × rate tokens, capped at capacity. A background thread "adding a token every 200 ms" for each of a million keys is a classic over-design.
# part 2: three algorithms behind the same interface
class TokenBucket(RateLimiter):
def __init__(self, capacity: int, refill_per_sec: float, clock: Clock) -> None:
self.capacity, self.rate, self.clock = capacity, refill_per_sec, clock
self.tokens = float(capacity)
self.last = clock.now()
self._lock = threading.Lock()
def try_acquire(self, cost: int = 1) -> Decision:
if cost > self.capacity:
raise ValueError(f"cost {cost} can never fit in a bucket of {self.capacity}")
with self._lock:
now = self.clock.now()
self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate) # lazy refill
self.last = now
if self.tokens >= cost:
self.tokens -= cost
return Decision(True, int(self.tokens), 0.0)
return Decision(False, int(self.tokens), (cost - self.tokens) / self.rate)
class FixedWindow(RateLimiter):
def __init__(self, limit: int, window: float, clock: Clock) -> None:
self.limit, self.window, self.clock = limit, window, clock
self.start, self.count = 0.0, 0
self._lock = threading.Lock()
def try_acquire(self, cost: int = 1) -> Decision:
with self._lock:
now = self.clock.now()
if now - self.start >= self.window:
self.start, self.count = now - (now % self.window), 0
if self.count + cost <= self.limit:
self.count += cost
return Decision(True, self.limit - self.count, 0.0)
return Decision(False, 0, self.start + self.window - now)
class SlidingWindowLog(RateLimiter):
def __init__(self, limit: int, window: float, clock: Clock) -> None:
self.limit, self.window, self.clock = limit, window, clock
self.log: deque[float] = deque()
self._lock = threading.Lock()
def try_acquire(self, cost: int = 1) -> Decision:
with self._lock:
now = self.clock.now()
while self.log and self.log[0] <= now - self.window:
self.log.popleft()
if len(self.log) + cost <= self.limit:
self.log.extend([now] * cost)
return Decision(True, self.limit - len(self.log), 0.0)
return Decision(False, 0, self.log[0] + self.window - now)The patterns, and why#
| Pattern | Where | Why it earns its place |
|---|---|---|
| Strategy | RateLimiter with three implementations |
The algorithm is chosen per rule. Adding a sliding window counter is a new class plus one factory line. |
| Factory | FACTORIES maps "token_bucket" to a constructor |
Rules come from config (a YAML file, a database row). The registry builds a limiter from a string without an if/elif ladder. |
| Registry with lazy creation | RateLimiterRegistry._limiter |
Limiters exist only for keys that have actually been seen. |
| Dependency injection | Clock |
Production uses the monotonic clock; tests use FakeClock. No sleeping in tests. |
Concurrency and edge cases#
There are two races, and you should name both.
Inside a limiter read tokens, compare, subtract. Two threads that both read "1 token left" both subtract and both get through. Each limiter holds its own lock around the whole read-compare-write.
In the registry a new client's first two requests arrive together. Both see "no limiter for this key", both create one, and the second overwrites the first; the client briefly has two buckets and twice its limit. The fix is double-checked creation: look without the lock (fast path, almost always a hit), and if it's missing, take the registry lock, look again, and create only if it's still missing. The registry lock is held only while creating, never while a request is being checked.
costlarger than capacity: it can never succeed; the bucket raises at once instead of returning aretry_afterthat lies- The clock jumps backwards: use a monotonic clock; with
time.time(), clamp negative elapsed time to zero - Idle keys pile up: a million one-time visitors leave a million buckets. Evict limiters not used for a while (an LRU of limiters, or a periodic sweep of full buckets, which carry no information)
- Floating-point refill:
0.4 s × 5/scan come out as1.9999…; keep tokens as floats but compare with care, or count in integer milli-tokens retry_aftermust be honest: for a window, the time until the window resets; for a bucket, the time to refill the missing tokens
The code#
Rules, the factory and the registry:
# part 3: rules, a factory that builds limiters from them, and the per-key registry
@dataclass(frozen=True)
class Rule:
algorithm: str # "token_bucket" | "fixed_window" | "sliding_log"
limit: int
window: float # seconds
LimiterFactory = Callable[[Rule, Clock], RateLimiter]
FACTORIES: dict[str, LimiterFactory] = {
"token_bucket": lambda r, c: TokenBucket(r.limit, r.limit / r.window, c),
"fixed_window": lambda r, c: FixedWindow(r.limit, r.window, c),
"sliding_log": lambda r, c: SlidingWindowLog(r.limit, r.window, c),
}
class RateLimiterRegistry:
"""Finds the rule for a (client tier, endpoint) and keeps one limiter per client key."""
def __init__(self, rules: dict[tuple[str, str], Rule], default: Rule, clock: Clock) -> None:
self.rules, self.default, self.clock = rules, default, clock
self._limiters: dict[tuple[str, str], RateLimiter] = {}
self._lock = threading.Lock() # only guards creation; each limiter has its own lock
def _limiter(self, client: str, tier: str, endpoint: str) -> RateLimiter:
key = (client, endpoint)
lim = self._limiters.get(key)
if lim is None:
with self._lock:
lim = self._limiters.get(key) # check again: another thread may have won
if lim is None:
rule = self.rules.get((tier, endpoint), self.default)
lim = FACTORIES[rule.algorithm](rule, self.clock)
self._limiters[key] = lim
return lim
def check(self, client: str, tier: str, endpoint: str, cost: int = 1) -> Decision:
return self._limiter(client, tier, endpoint).try_acquire(cost)The demo runs entirely on a FakeClock, so it takes milliseconds and prints the same thing every time:
# part 4: the behaviour, checked with a fake clock
if __name__ == "__main__":
clock = FakeClock(start=1000.0)
rules = {
("free", "/otp"): Rule("fixed_window", 3, 60),
("free", "/search"): Rule("token_bucket", 5, 1),
("pro", "/search"): Rule("token_bucket", 50, 1),
}
reg = RateLimiterRegistry(rules, Rule("sliding_log", 10, 60), clock)
burst = [reg.check("u1", "free", "/search").allowed for _ in range(7)]
print("burst of 7:", burst) # 5 True, then False False
clock.advance(0.5) # 0.5 s x 5 tokens/s = 2.5 tokens back
print("after 0.5s:", [reg.check("u1", "free", "/search").allowed for _ in range(3)]) # True True False
otp = [reg.check("u2", "free", "/otp") for _ in range(4)]
print("otp:", [d.allowed for d in otp], "retry in", round(otp[-1].retry_after, 1), "s") # 3 True, 1 False, 19.5 s
# fixed window's flaw: 3 at the end of one window + 3 at the start of the next = 6 in 2 seconds
fw = FixedWindow(3, 60, clock)
clock.t = 1199.0
a = sum(fw.try_acquire().allowed for _ in range(3))
clock.t = 1201.0
b = sum(fw.try_acquire().allowed for _ in range(3))
sl = SlidingWindowLog(3, 60, clock)
clock.t = 1199.0
c = sum(sl.try_acquire().allowed for _ in range(3))
clock.t = 1201.0
d = sum(sl.try_acquire().allowed for _ in range(3))
print("fixed window let through", a + b, "in 2 s; sliding log", c + d) # 6 vs 3
# 20 threads, 100 calls each, against one bucket of 50 with no time passing: exactly 50 allowed
shared = TokenBucket(50, 1.0, FakeClock())
allowed = []
def hammer() -> None:
allowed.append(sum(shared.try_acquire().allowed for _ in range(100)))
ts = [threading.Thread(target=hammer) for _ in range(20)]
for t in ts:
t.start()
for t in ts:
t.join()
print("allowed under contention:", sum(allowed)) # 50burst of 7: [True, True, True, True, True, False, False]
after 0.5s: [True, True, False]
otp: [True, True, True, False] retry in 19.5 s
fixed window let through 6 in 2 s; sliding log 3
allowed under contention: 50The fourth line is the boundary flaw made visible: a limit of 3 a minute, and 6 requests got through in two seconds because they straddled the window edge. The sliding log lets through 3. The last line proves the per-limiter lock: 2,000 attempts from 20 threads, exactly 50 granted.
Extending it: the follow-ups#
- "The sliding log uses too much memory for a limit of 10,000 a minute." Add a sliding window counter: keep the count of the current and previous fixed windows, and estimate
previous × (overlap fraction) + current. O(1) memory, close to exact. It is one new class and one factory entry. - "Make it a decorator on our functions." A Python decorator
@rate_limited(registry, endpoint="/search")that pulls the client from the request and raisesTooManyRequests(retry_after)when denied. The limiter classes don't change. - "Return the standard headers."
Decisionalready has whatX-RateLimit-RemainingandRetry-Afterneed; the HTTP layer formats them. - "Now we have 20 servers." The interface stays; the implementation changes. A
RedisTokenBucketimplementsRateLimiterwith a Lua script that does the refill-and-take atomically on the Redis server. That is the system design question, and this design is ready for it. - "Different limits change at runtime." Rules live behind the registry; on a rule change, drop the affected limiters and let them be recreated lazily with the new rule.
Common mistakes#
- One class with an
algorithmstring and anif/elifper method, instead of one class per algorithm time.sleepin tests, or a hiddentime.time()call that makes the demo non-deterministic- A background refill thread per bucket
- A global lock around every check, so one hot client slows down every other client
- Creating limiters without the double check, so a burst of first requests creates several buckets for one key
- Returning a bare
bool, so the API can't tell the client when to come back
Rate limiter
Q1/3 A fixed window allows 3 requests a minute. A client sends 3 at 12:00:59 and 3 at 12:01:01. How many get through?
Answer & explanation
Answer: C
Fixed windows reset at the boundary, so 3 + 3 pass within two seconds: up to twice the limit. A sliding log or a sliding window counter closes that gap.
Q2/3 Two first requests for a brand-new client arrive at the same moment. What does double-checked creation prevent?
Answer & explanation
Answer: B
Without the second check inside the lock, both threads see "missing", both create a limiter, and one overwrites the other. Checking again under the lock makes creation happen exactly once.
Q3/3 Why does the token bucket refill lazily instead of using a timer thread?
Answer & explanation
Answer: A
The bucket only needs to be right when someone asks. Computing the refill from elapsed time on each call costs O(1) and needs no background work, even for a million keys.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.