Rate Limiting, Quotas & Abuse Protection · Distributed Implementation
Distributed Limiters with Redis and Lua
Atomic, shared limits across many app instances.
With many instances, limits must be shared. Redis is the common choice: run the algorithm as a Lua script (atomic, one round trip), keep keys with TTLs, use Redis Cluster hash tags if multiple keys must be in the same slot, and decide failure behavior: fail open (allow when Redis is down) for availability or fail closed for security-critical endpoints like login.
When the limiter store fails
| Failure mode | Fail open | Fail closed |
|---|---|---|
| Effect when Redis is down | No limiting | All requests rejected |
| Use for | General API traffic | Login, OTP, password reset, costly ops |
| Risk | Abuse during outage | Self-inflicted outage |
const tokenBucket = fs.readFileSync("token_bucket.lua", "utf8");
redis.defineCommand("tokenBucket", { numberOfKeys: 1, lua: tokenBucket });
export async function limit(key: string, capacity: number, perSec: number, cost = 1) {
try {
const [ok, remaining, retryAfter] = await (redis as any).tokenBucket(`rl:{${key}}`, capacity, perSec, Date.now(), cost);
return { ok: ok === 1, remaining, retryAfter };
} catch {
return { ok: true, remaining: -1, retryAfter: 0 }; // fail open (not for login!)
}
}
token_bucket = redis.register_script(open("token_bucket.lua").read())
def limit(key: str, capacity: int, per_sec: float, cost: int = 1, fail_open: bool = True):
try:
ok, remaining, retry_after = token_bucket(keys=[f"rl:{{{key}}}"], args=[capacity, per_sec, int(time.time() * 1000), cost])
return bool(ok), remaining, retry_after
except redis.RedisError:
return fail_open, -1, 1
// Bucket4j with a Redis (Lettuce) proxy manager: distributed token buckets
ProxyManager<String> buckets = LettuceBasedProxyManager.builderFor(redisClient)
.withExpirationStrategy(ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(1)))
.build();
BucketConfiguration plan = BucketConfiguration.builder()
.addLimit(Bandwidth.builder().capacity(200).refillGreedy(1000, Duration.ofMinutes(1)).build())
.build();
ConsumptionProbe probe = buckets.builder().build("key:" + apiKey, () -> plan).tryConsumeAndReturnRemaining(1);
if (!probe.isConsumed()) {
long retryAfterSec = TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill()) + 1;
// respond 429 with Retry-After
}
Common pitfalls
- GET then SET from the app (race conditions).
- Hot keys on one Redis shard for global limits.
- Adding a Redis round trip to every request without timeouts.
Key points
- Lua for atomic algorithms
- TTL every key
- Decide fail-open vs fail-closed per endpoint
Further reading: Redis: Lua scripting
Practice questions
Why run rate-limit logic as a Lua script in Redis?
Answer
The read-compute-write sequence executes atomically on the server in one round trip, avoiding races between concurrent requests from different app instances.
When should a limiter fail closed?
Answer
For security-sensitive endpoints (login, OTP, password reset) where allowing unlimited attempts during an outage is riskier than temporary unavailability.
Open in the app to mark lessons complete, take notes and track your progress.