All lessonsDay 16A-Foundations2026-10-12

Rate Limiting and Consistent Hashing: From Provided Source

A client sends 3 requests in one second to an API that allows 2 per second. Two requests run. The third gets HTTP 429. That split second decision protects cost, latency, and survival.

300 / 3hTwitter: 300 tweets per 3 hours.
300 / 60sGoogle Docs API: 300 per user per 60 seconds for reads.
2 / 10 / 5Example throttle rules: 2 posts per second. 10 accounts per day per IP. 5 reward claims per week per device.
4 cap, 2/sToken example: bucket capacity 4. Refill 2 per second. Overflow is dropped.
5 becomes 10Fixed window failure: limit 5 per minute allows 10 in rolling 2:00:30 to 2:01:30.
0.003%Sliding counter test: 0.003% wrong decisions in 400 million requests per Cloudflare experiment.
194 edgesEdge scale: Cloudflare had 194 edge locations as of 5/20/2020.
0 to 2^160-1Hash space: SHA-1 range 0 to 2^160 minus 1.
10% / 5%Balance: 100 virtual nodes give about 10% stddev. 200 give about 5%.

Key points

  • Permission can be modeled as tokens added over time.
  • History can be modeled as counts inside time windows.
  • Naive hash(key) % N remaps nearly all keys when N changes.
  • A hash ring remaps only keys next to the changed server.
  • Distribution needs central state or requests cannot be counted correctly.
01Derive token consumption and refill for a given timestamp trace.
02Break down why a window edge allows double quota.
03Argue sticky sessions versus central Redis for sync.
04Predict affected key range when a node joins or leaves a ring.
05Choose an algorithm under burst versus accuracy tradeoff.

Note on assignment: assigned lesson was Data Models and Query Languages (DDIA ch2). The provided chapter text contains rate limiter design, consistent hashing, and key-value store intro. Per grounding rules this draft teaches only the provided text.

R0

Requirements: six goals for the limiter


The source lists six goals for the limiter. We will test every algorithm against them.

  1. R1Accurately limit excessive requests.
  2. R2Low latency. Do not slow HTTP response.
  3. R3Use as little memory as possible.
  4. R4Distributed rate limiting across servers or processes.
  5. R5Exception handling. Show clear throttle signal.
  6. R6High fault tolerance. Cache failure must not take down system.
01

Problem: unlimited requests break cost and load


A public API faces intentional flood and accidental loops. Bots retry. Users misbehave. Paid third party calls charge per call. Examples from text are credit check, payment, and health record retrieval.

Without a gate, one caller starves others. This breaks (1). It also breaks (2) because queues grow. It breaks cost because more servers are needed for junk.

The gate can sit in two places. Option A is inside API servers. Option B is rate limiter middleware in front of them. A common realization of B is an API gateway. Gateway choice depends on stack, control of algorithm, existing auth use, and engineering time.

Try it: toggle where the gate lives. Watch the same post request travel each path.

Client → API server [check + serve]

One hop. Full algorithm control in server code.

Same user post takes one path or two. Option A keeps logic in the server. Option B centralizes it in a gateway.
02

Mental model: buckets grant permission. Windows remember history


Picture two tools.

A bucket holds tokens. Requests spend tokens. A refiller adds tokens on a timer. Full bucket means extra tokens overflow and vanish.

A window remembers timestamps or counts. New request asks if recent history is over quota. Old history expires.

Token bucket and leaking bucket are bucket tools. Fixed window counter, sliding window log, and sliding window counter are window tools. Buckets allow short bursts if tokens remain. Windows enforce rate over time.

Contrast: spend from a bucket on the left, expire history on the right. Both guard the same user post.

Bucket view

Tokens = permission

  • Burst OK if tokens saved
  • Overflow vanishes when full
Window view

History = memory

  • Rate enforced over time
  • Old entries must expire
Buckets ask do you have a token. Windows ask how many did you send lately.
03

Mechanism: five algorithms stepped live


A. Token bucket.

Parameters are bucket size and refill rate. Example from text is size 4 and refill 4 per 1 minute. Start full with 4.

Rule per endpoint needs its own bucket. Example is 1 post per second plus 150 friends per day plus 5 likes per second needs 3 buckets per user. Per IP needs one bucket per IP. Global limit of 10,000 requests per second needs one shared bucket.

Operable: set size and refill, then replay the text trace or send your own post. Overflow is dropped.

4
4
Tokens
4

capped at size

Allowed
0

went through

Dropped
0

HTTP 429

Press Replay text trace to follow one user post stream.
Text trace uses capacity 4 and refill 4 per minute. Extra refill overflows and vanishes.

B. Leaking bucket.

Think FIFO queue plus fixed outflow. Parameters are queue size and outflow rate.

Shopify uses this shape when stable outflow matters.

Animate: burst 5 posts at once, then watch the worker drain one per second.

worker: 1 per sec
Queue size 5. Stable outflow protects downstream.
New bursts wait behind old work. When full, new arrivals are dropped.

C. Fixed window counter.

Divide time into fixed windows. Keep one counter per window. Each request increments. Over threshold means drop until next window. Example allows 3 per second.

Failure case included. Limit is 5 per minute with reset on round minute. Five arrive 2:00:00 to 2:01:00. Five more arrive 2:01:00 to 2:02:00. Rolling interval 2:00:30 to 2:01:30 sees 10. That is twice quota. This breaks (1).

Before / after: switch views. Fixed windows look fine. The rolling window reveals 10 in 60 seconds.

2:00:00 — 2:01:00 — 2:02:00 (limit 5 per minute)

Each fixed window sees 5. Under limit.

Reset on the round minute causes the edge burst. Rolling 2:00:30 to 2:01:30 sees double quota.

D. Sliding window log.

Store each request timestamp, often in Redis sorted sets. On new request, remove timestamps older than start of current window. Insert new timestamp. If log size is within allowed count, accept. Else reject.

Trace with limit 2 per minute:

This is accurate in any rolling window.

Scrub time: drag to walk the same four posts. Old stamps expire out of the window.

1:00:01 — step 1 of 4

Size 1. Allow.

Limit 2 per minute. Rejected stamp at 1:00:50 stays stored, which costs memory.

E. Sliding window counter.

This is a hybrid of fixed counter and sliding log. Formula from text:

estimate = current_window_count + previous_window_count * overlap_fraction

Worked example: limit 7 per minute. Previous has 5. Current has 3. New request at 30% into current minute means overlap with previous is 70%. Estimate is 3 + 5 * 0.7 = 6.5. Round down to 6 in example. Allow because 6 is under 7. Next request would hit limit.

High level storage uses Redis. INCR adds 1 to counter. EXPIRE sets timeout so counter deletes itself. Flow is client to middleware to Redis check to API servers or 429. Headers returned are X-Ratelimit-Remaining and X-Ratelimit-Limit and X-Ratelimit-Retry-After.

Rules live on disk as config. Workers pull rules into cache. Example Lyft rules are 5 marketing messages per day and 5 logins per minute. Exceeded requests return 429. Some use cases enqueue to message queue for later instead of drop, such as overload limited orders.

Calculate: move overlap to see the estimate cross the limit of 7.

5
3
70%
Estimate
6.5

current + prev x overlap

Rounded
6

round down per text

Decision
Allow

limit 7 per minute

3 + 5 x 0.7 = 6.5. Under 7, so allow. Next request would hit limit.

Hybrid is cheap but assumes even spread in the previous window.
04

Weakness and fix: memory, tuning, race, sync, distance


Weaknesses stated honestly:

Fixes and their price:

Sync test: send split traffic, then swap limiters. Compare isolated counts to a shared Redis count.

Limiter 1 sees
0
isolated view
Limiter 2 sees
0
isolated view

No limiter has the full count. Central Redis would see the true total.

Sticky pinning is inflexible. Shared store satisfies (4).

Race in one click: two threads read 3 at once.

Press Run race to see the lost update.

Locks fix the race but break (2). Lua script keeps atomicity without locks.
05

Consistent hashing: ring walk


Hash both servers and keys with same function onto ring. SHA-1 space wraps from max back to 0. To locate a key, walk clockwise from key position to first server. Example mapping is key0 to server 0, key1 to server 1, key2 to server 2, key3 to server 3.

Add server 4 near key0. Only key0 moves from server 0 to server 4. Others stay. Remove server 1. Only key1 moves to server 2. Others stay. Quote from text is only k/n keys remap on average, where k is keys and n is slots.

Affected range rule:

Virtual nodes make partitions fine grained. Instead of s0, place s0_0, s0_1, s0_2. Lookup finds first virtual node, then maps to real server. More replicas lower variance but cost storage.

Key value context from text start: pair size is small, under 10 KB. Operations are put(key, value) and get(key). Goals are big data, high availability, high scalability, automatic scaling, tunable consistency, low latency. Hashing provides the scaling and auto membership part.

Operate the ring: add s4 or remove s1. Only neighbors move.

SHA-1 wraps from max back to 0

key0 to s0, key1 to s1, key2 to s2, key3 to s3.

Add moves keys in (s3, s4] to s4. Remove moves keys in (s0, s1] to s2.

Balance: pick replica count. Watch clumping fade as virtual nodes grow.

With 1 replica, most keys land on server 2 and none on server 1 and 3 in the text clump example.

More replicas lower stddev but cost storage. Tune count to fit needs.
06

Code: runnable sketches with notes that matter


Show code
import time
from bisect import bisect

# --- Token bucket: capacity 4, refill 4 per 60s (text example) ---
class TokenBucket:
    def __init__(self, capacity, refill_per_sec):
        self.capacity = capacity
        self.tokens = capacity
        self.refill_per_sec = refill_per_sec
        self.last = time.monotonic()
    def allow(self):
        now = time.monotonic()
        # Line that matters: refill by elapsed time, cap at capacity (overflow vanishes)
        self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.refill_per_sec)
        self.last = now
        if self.tokens >= 1:
            self.tokens -= 1
            return True
        return False

# --- Fixed window counter: 3 per 1s (text example) ---
class FixedWindow:
    def __init__(self, limit, window_sec):
        self.limit = limit
        self.window_sec = window_sec
        self.window_start = int(time.time())
        self.count = 0
    def allow(self):
        now = int(time.time())
        # Line that matters: reset on window boundary (cause of edge burst)
        if now - self.window_start >= self.window_sec:
            self.window_start = now
            self.count = 0
        self.count += 1
        return self.count <= self.limit

# --- Sliding counter estimate: 3 + 5*0.7 = 6.5 (text example) ---
def sliding_estimate(current, previous, overlap_frac):
    # Line that matters: weight previous window by overlap
    return current + previous * overlap_frac

# --- Consistent hash ring with virtual nodes ---
class Ring:
    def __init__(self):
        self.points = []  # list of (hash, server)
    def add_server(self, server, replicas):
        # Line that matters: many points per server smooth partitions
        for i in range(replicas):
            h = hash(f"{server}#{i}") % (2**32)  # illustrative hash, not SHA-1
            self.points.append((h, server))
        self.points.sort()
    def lookup(self, key):
        hs = [p[0] for p in self.points]
        h = hash(key) % (2**32)  # illustrative
        # Line that matters: clockwise walk = first point >= h, wrap to 0
        idx = bisect(hs, h) % len(hs)
        return self.points[idx][1]

if __name__ == "__main__":
    b = TokenBucket(capacity=4, refill_per_sec=4/60)
    print([b.allow() for _ in range(5)])  # expect 4 True then False
    print(sliding_estimate(3, 5, 0.7))  # expect 6.5
    r = Ring()
    r.add_server("s0", 3); r.add_server("s1", 3)
    print(r.lookup("k0"))

Expected output

[True, True, True, True, False]
6.5
s0 or s1 (depends on illustrative hash)
07

Field guide and interview ears


Field guide, each with WHY from text:

Interview ears:

Hard vs soft: when to allow a short exceed

Choose soft for flash sale bursts where token bucket absorbs the spike. Choose hard for paid quota such as credit check or payment where over allow breaks cost. Headers X-Ratelimit-Remaining, X-Ratelimit-Limit, and X-Ratelimit-Retry-After tell the client which mode hit.

QZ

Judgment quiz


1Predict: limit 5 per minute fixed window. Six requests arrive at 2:00:59 and six at 2:01:01. How many go through and what does rolling 2:00:30 to 2:01:30 see?

Answer: 10 go through, 5 in each fixed window. Rolling window sees 12 arrivals with 10 allowed, double quota. Shows edge weakness.

2Compare: sliding log versus sliding counter for strict bank transfer limit where over allow breaks (1) badly, but memory budget breaks (3) if large.

Cite Cloudflare 0.003% error as price of counter approximation. For strict lookback, log is accurate but stores even rejected timestamps.

3Choose under tradeoff: flash sale with burst plus far users plus two limiter servers seeing split traffic.

Accept short inconsistency and tuning cost. If Redis fails, fail open to protect (6) and monitor. Relax rules if valid traffic drops.

Sources: provided chapter text on rate limiter design, consistent hashing, and key-value store intro (token bucket, leaking bucket, fixed and sliding windows, Redis INCR and EXPIRE, Lyft rate limit component, Cloudflare 400M request experiment, SHA-1 ring, virtual nodes, Dynamo, Cassandra, Discord, Akamai, Maglev). Pair size under 10 KB with put and get operations.