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.
Key points
hash(key) % N remaps nearly all keys when N changes.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.
The source lists six goals for the limiter. We will test every algorithm against them.
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.
One hop. Full algorithm control in server code.
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.
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.
capped at size
went through
HTTP 429
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.
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.
Each fixed window sees 5. Under limit.
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.
Size 1. Allow.
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.
current + prev x overlap
round down per text
limit 7 per minute
3 + 5 x 0.7 = 6.5. Under 7, so allow. Next request would hit limit.
Weaknesses stated honestly:
serverIndex = hash(key) % N breaks on membership change. With 4 servers and 8 keys, removing one server remaps most keys, not just keys on dead server. That storm breaks (2) and (6).read, check, increment has race condition. Counter is 3. Two threads read 3. Both write 4. Correct is 5. Locks fix it but slow system and break (2).Fixes and their price:
Sync test: send split traffic, then swap limiters. Compare isolated counts to a shared Redis count.
No limiter has the full count. Central Redis would see the true total.
Race in one click: two threads read 3 at once.
Press Run race to see the lost update.
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.
key0 to s0, key1 to s1, key2 to s2, key3 to s3.
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.
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)
Field guide, each with WHY from text:
Interview ears:
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.
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.