~learn.

LLD 0311 min+60 XP

Design a Rate Limiter.

Design a pluggable, in-process rate limiter, then make it run

5 code

Lesson · 11 min

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.

ExplainerThe whole design: Rate limiter

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#

Interviewer

"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."

A good 90 minutes
  • 0–10 min —
    clarify: per what key, which algorithms, what the caller gets back, bursts, thread safety
  • 10–20 min —
    the interface, the Decision, the Clock, the Rule
  • 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
Your turn · checkpoint 1not answered

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?

Think it through, or jot it down. Then compare.

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#

DiagramRate limiter: 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" RateLimiter

One 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.

pythonClock · 44 lines
# 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.

Pick the algorithm per rule

Token bucket

  • Holds up to capacity tokens, refilled at rate per 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 limit in 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.

pythonTokenBucket · 53 lines
# 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.

Edge cases
  • cost larger than capacity: it can never succeed; the bucket raises at once instead of returning a retry_after that 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/s can come out as 1.9999…; keep tokens as floats but compare with care, or count in integer milli-tokens
  • retry_after must 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:

pythonRule · 39 lines
# 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:

pythonhammer · 44 lines
# 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))  # 50
text5 lines
burst 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: 50

The 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 raises TooManyRequests(retry_after) when denied. The limiter classes don't change.
  • "Return the standard headers." Decision already has what X-RateLimit-Remaining and Retry-After need; the HTTP layer formats them.
  • "Now we have 20 servers." The interface stays; the implementation changes. A RedisTokenBucket implements RateLimiter with 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.
✗ Pitfall

Common mistakes#

What costs candidates the round
  • One class with an algorithm string and an if/elif per method, instead of one class per algorithm
  • time.sleep in tests, or a hidden time.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
Quick check3 questions · +5 XP each

Rate limiter

  1. 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.

  2. 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.

  3. 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.

End of LLD 03 · Design a Rate Limiter
Finish LLD 03Stages clear as you read them (0/1 so far). Skipping ahead doesn't count: marking complete unlocks after ~2 more min of reading.

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.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…