The Quest Begins (The "Why")
I was tasked with building a simple rate limiter for a public API. The idea sounded easy: count requests per IP and reject when the limit is hit. I threw together a quick in‑memory counter, deployed it behind a couple of nodes, and called it a day.
Then reality hit. One night a network glitch split our cluster in half. Requests started flowing to both sides, each side kept its own counter, and the limit was effectively doubled—or worse, completely ignored. Users got through, the service stayed up, but our guarantees went out the window. I felt like I’d just taken the red pill and seen the true nature of the system.
That moment sparked the question: What are we actually sacrificing when we choose one design over another? The answer lay in a concept I’d heard whispered about in architecture meetings but never truly internalized—the CAP theorem.
The Revelation (The Insight)
The CAP theorem states that in any distributed data store you can only guarantee two out of three properties:
- Consistency – every read sees the most recent write.
- Availability – every request receives a response (non‑error) without guarantee it’s the latest data.
- Partition tolerance – the system continues to operate despite network partitions.
You can’t have all three because a partition forces you to pick between staying consistent (by refusing writes) or staying available (by allowing writes that may diverge).
Here’s a quick ASCII picture to visualize the trade‑off:
+--------+ +--------+
| Node1 |<------>| Node2 |
+--------+ +--------+
^ ^
| |
(network) (network)
| |
Partition? |
| |
If partition -> Choose:
- Consistency (CP): reject writes on minority side
- Availability (AP): allow writes, risk divergence
The “critical insight” for me was realizing that the choice isn’t about being right or wrong; it’s about matching the guarantee to the problem you’re solving. For a rate limiter, do we need perfect counts (strong consistency) or is it okay to be slightly off as long as the limiter stays responsive?
Wielding the Power (Code & Examples)
The Struggle – a Naïve, Single‑Node Limiter
# naive_limiter.py
import time
from collections import defaultdict
class NaiveLimiter:
def __init__(self, limit: int, window_sec: int):
self.limit = limit
self.window = window_sec
self.hits = defaultdict(list) # ip -> timestamps
def allow(self, ip: str) -> bool:
now = time.time()
window_start = now - self.window
# prune old entries
self.hits[ip] = [ts for ts in self.hits[ip] if ts >= window_start]
if len(self.hits[ip]) < self.limit:
self.hits[ip].append(now)
return True
return False
This works fine as long as there’s exactly one process. Deploy it behind a load balancer with two instances, and each instance keeps its own hits dict. Under a network partition, each side can independently allow up to limit requests, effectively doubling the throughput. The system is available and partition tolerant (AP) but not consistent—the count you see depends on which node you hit.
The Victory – a Distributed Limiter with a Clear CAP Choice
Now let’s move the counter to Redis, a single source of truth we can treat as CP or AP depending on how we configure it.
Option 1: CP – Strong Consistency (reject on minority side)
# cp_limiter.py
import time
import redis
r = redis.Redis(host='redis-primary', port=6379, db=0)
def allow_cp(ip: str, limit: int, window: int) -> bool:
key = f"rate:{ip}"
now = int(time.time())
# Use a Redis Lua script for atomic increment + expire
lua = """
local current = redis.call('GET', KEYS[1])
if current == false then
redis.call('SET', KEYS[1], 1)
redis.call('EXPIRE', KEYS[1], ARGV[2])
return 1
end
if tonumber(current) < tonumber(ARGV[1]) then
redis.call('INCR', KEYS[1])
return tonumber(current) + 1
end
return tonumber(current)
"""
current = r.eval(lua, 1, key, limit, window)
return current <= limit
Why CP?
If the Redis node becomes unreachable (a partition), the script throws an error and we fail closed—we reject the request rather than risk an inaccurate count. We sacrifice availability (the API may return 503) but keep the guarantee that the count is always correct when we do respond.
Option 2: AP – Eventual Consistency (allow, converge later)
# ap_limiter.py
import time
import redis
# Redis configured with replica‑read‑only mode or a simple cache layer
r = redis.Redis(host='redis-cluster', port=6379, db=0, socket_timeout=0.2)
def allow_ap(ip: str, limit: int, window: int) -> bool:
key = f"rate:{ip}"
try:
# Increment with a short timeout; if Redis is down we treat it as a miss
current = r.incr(key)
if current == 1:
r.expire(key, window)
return current <= limit
except (redis.ConnectionError, redis.TimeoutError):
# On partition we optimistically allow the request
# (you could also log and fallback to a local counter)
return True
Why AP?
When a partition occurs, the limiter stays available—we let the request through (or fallback to a local counter) and later reconcile the counts when the partition heals. The system may temporarily exceed the limit, but it never blocks users. This is classic AP behavior: we favor availability and partition tolerance, accepting a temporary inconsistency.
Common Traps to Avoid
- Treating Redis as a magic wand – If you point all instances at a single Redis without considering its own failure mode, you’ve simply moved the single‑point‑of‑failure problem elsewhere.
- Ignoring latency – Synchronous CP calls can add tens of milliseconds; if your API needs sub‑10ms responses, you may need to tune timeouts or adopt an AP approach with local buffering.
Why This New Power Matters
By explicitly framing the rate limiter through the lens of CAP, I stopped guessing and started designing with intent.
- When I needed strict guarantees (e.g., preventing abuse that could trigger billing spikes), I went with the CP version and accepted the occasional 503 during a network hiccup.
- When the goal was keeping the API responsive for a consumer‑facing app (where a few extra requests won’t break the bank), I switched to the AP variant and added a background job to reconcile counters after a partition healed.
The trade‑off is no longer a mysterious dragon; it’s a choice I can make, measure, and adjust.
Your Turn
Think about a piece of infrastructure you’re building right now—a cache, a leader‑election service, a feature flag store. Ask yourself: Which two of C, A, P do I truly need right now? Sketch a quick diagram, write a tiny prototype, and see how the decision shapes your code.
What’s the first system you’ll re‑examine with this CAP lens? Drop your thoughts in the comments—I’d love to hear about your quest!
Top comments (0)