Rate Limiting Distribuído: token bucket, sliding window, Redis
Rate limiting é o que separa API séria de APIs que caem toda vez que um cliente bugado entra em loop. Sem rate limit, você não tem proteção contra: (1) thundering herd de retries amplificando incidentes, (2) custo descontrolado em LLM APIs ou serviços pagos por uso, (3) DoS de um cliente mal-configurado matando os outros, (4) abuse de quota em free tier.
Este módulo fecha a trilha de sistemas distribuídos com os 4 algoritmos clássicos de rate limiting (token bucket, leaky bucket, fixed window, sliding window), uma implementação real em Redis + Lua (atômica), e como distribuir o limite em cluster de N réplicas de API sem dupla contagem.
Por que rate limiting é difícil em sistemas distribuídos
Numa única máquina, rate limit é trivial: contador em memória, incrementa e checa. O problema vira interessante quando você tem:
- N réplicas da API atrás de load balancer
- Mesma API key pode chegar em qualquer réplica
- Contadores locais divergem — cliente com 100 req/s pode passar pq cada réplica vê só 20
- Usar storage externo (Redis, DynamoDB) introduz latência e ponto de falha
- Precisa ser atômico — get+check+incr pode ter race condition
Duas abordagens:
Os 4 algoritmos clássicos
| Algoritmo | Intuição | Permite burst? | Memória | Precisão |
|---|---|---|---|---|
| Fixed Window Counter | Contador por janela (ex: 00:00-00:59) | Sim (mas em boundary — 2x no limite) | O(1) por cliente | Baixa no boundary |
| Sliding Window Log | Guarda timestamp de cada req | Conforme limite | O(N) — N reqs na janela | Máxima |
| Sliding Window Counter | Interpola entre 2 janelas fixas | Suave | O(1) | Alta (aproximação ótima) |
| Token Bucket | Balde com capacidade C, refill R/seg | Sim (até C tokens) | O(1) | Alta |
| Leaky Bucket | Fila FIFO com saída constante | Não (saída suave) | O(N) da fila | Alta |
Fixed Window Counter
# fixed_window.py
import time
import redis.asyncio as redis
r = redis.from_url("redis://localhost")
async def allow(client_id: str, limit: int = 100, window_s: int = 60) -> bool:
now_window = int(time.time()) // window_s
key = f"rate:{client_id}:{now_window}"
async with r.pipeline(transaction=True) as pipe:
pipe.incr(key)
pipe.expire(key, window_s) # idempotente
count, _ = await pipe.execute()
return count <= limitSliding Window Log
Guarda o timestamp de cada req numa estrutura ordenada (Redis sorted set, ZSET). A cada req: remove timestamps mais velhos que a janela, conta os restantes, se < limite adiciona.
# sliding_window_log.py — preciso mas mais memória
import time
import redis.asyncio as redis
import uuid
r = redis.from_url("redis://localhost")
async def allow(client_id: str, limit: int = 100, window_s: int = 60) -> bool:
now_ms = int(time.time() * 1000)
window_start = now_ms - (window_s * 1000)
key = f"rate:log:{client_id}"
async with r.pipeline(transaction=True) as pipe:
pipe.zremrangebyscore(key, 0, window_start) # expire old
pipe.zcard(key) # count
pipe.zadd(key, {f"{now_ms}:{uuid.uuid4()}": now_ms}) # add new
pipe.expire(key, window_s)
_, count, _, _ = await pipe.execute()
return count < limit # < pq o novo já foi adicionadoTradeoff: memoriza até limit timestamps por cliente. Pra limite de 100/min e 10k clientes ativos: 1M entries. Em limites altos (10k/s), memória explode. Use sliding window counter pra aproximar.
Sliding Window Counter (o sweet spot)
Conceito da Cloudflare (How we built rate limiting capable of scaling to millions of domains, 2017): interpola entre 2 janelas fixas pra aproximar uma janela deslizante sem armazenar cada timestamp.
# sliding_window_counter.py
import time
async def allow(client_id: str, limit: int = 100, window_s: int = 60) -> bool:
now = time.time()
cur_win = int(now) // window_s
prev_win = cur_win - 1
percent_through = (now % window_s) / window_s
cur_key = f"rate:sw:{client_id}:{cur_win}"
prev_key = f"rate:sw:{client_id}:{prev_win}"
cur_count = int(await r.get(cur_key) or 0)
prev_count = int(await r.get(prev_key) or 0)
estimated = prev_count * (1 - percent_through) + cur_count
if estimated >= limit:
return False
await r.incr(cur_key)
await r.expire(cur_key, window_s * 2) # manter visível 1 janela extra
return TruePrecisão: estudos da Cloudflare mostram erro < 0.003% comparado ao sliding window log, com 1 ordem de magnitude menos memória. Hoje é o padrão de fato pra APIs de escala — NGINX, Cloudflare, Kong usam variantes.
Token Bucket (o preferido de APIs públicas)
Balde com capacidade C tokens. Refill a R tokens/segundo (até C). Cada req consome 1 token. Se o balde está vazio: reject. Permite bursts até C, mantendo taxa média R.
-- token_bucket.lua — atômico via EVAL no Redis
-- KEYS[1] = chave do bucket
-- ARGV[1] = capacidade (C)
-- ARGV[2] = refill rate (R tokens/seg)
-- ARGV[3] = now (unix seconds, com decimais)
-- ARGV[4] = tokens requested (tipicamente 1)
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local data = redis.call('HMGET', key, 'tokens', 'last')
local tokens = tonumber(data[1])
local last = tonumber(data[2])
if tokens == nil then
tokens = capacity
last = now
end
-- Refill: adiciona tokens proporcional ao tempo passado, cap em capacity
local elapsed = math.max(0, now - last)
tokens = math.min(capacity, tokens + elapsed * refill_rate)
local allowed = 0
if tokens >= requested then
tokens = tokens - requested
allowed = 1
end
redis.call('HMSET', key, 'tokens', tokens, 'last', now)
redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) + 1)
return {allowed, tokens}# token_bucket_client.py — chamando o script
import time
import redis.asyncio as redis
r = redis.from_url("redis://localhost")
# Carrega uma vez, reusa via EVALSHA
with open("token_bucket.lua") as f:
TOKEN_BUCKET_SHA = None
async def init_script():
global TOKEN_BUCKET_SHA
with open("token_bucket.lua") as f:
TOKEN_BUCKET_SHA = await r.script_load(f.read())
async def allow(client_id: str, capacity: int = 10, rate: float = 5.0) -> tuple[bool, float]:
now = time.time()
key = f"rate:bucket:{client_id}"
allowed, remaining = await r.evalsha(
TOKEN_BUCKET_SHA, 1, key, capacity, rate, now, 1
)
return bool(allowed), remainingPor que token bucket é querido de APIs públicas: AWS, GitHub, Stripe, Google Cloud — todos usam variantes. Razão: é amigável pro cliente. Burst é permitido até C, então um script bem-escrito que faz 20 reqs rápidas seguidas de silêncio não é punido. Clientes mal-escritos (loop sem sleep) sentem rapidinho.
Leaky Bucket (taxa constante)
Fila FIFO de capacidade C com saída constante R/seg. Se a fila está cheia, req é rejeitada. Processamento suave — não permite burst na saída. Útil pra proteger backends que não aguentam picos (ex: worker thread pool fixo).
# leaky_bucket.py — versão educativa em memória
from collections import deque
import time
class LeakyBucket:
def __init__(self, capacity: int, rate_per_s: float):
self.capacity = capacity
self.rate = rate_per_s
self.queue = deque()
self.last_leak = time.time()
def allow(self) -> bool:
now = time.time()
# Leak: remove itens proporcional ao tempo passado
to_leak = int((now - self.last_leak) * self.rate)
for _ in range(min(to_leak, len(self.queue))):
self.queue.popleft()
self.last_leak = now
if len(self.queue) >= self.capacity:
return False
self.queue.append(now)
return TrueToken vs Leaky: mesmo comportamento em "regime steady state" (R/seg), mas token permite burst até C instantaneamente. Leaky força saída suave. Escolha token pra APIs públicas (UX amigável), leaky pra gate de sistemas com capacidade fixa.
Rate limit em HTTP: o que devolver
Boas práticas de API (seguindo RFC 6585 e padrões Stripe/GitHub):
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1744803660
{
"error": "rate_limit_exceeded",
"message": "Too many requests. Try again in 30 seconds.",
"retry_after": 30
}- Status 429 pra rate limit. 503 pra overload geral.
- em segundos — SDKs usam isso pra backoff.
- em todas as respostas (200 e 429) pra cliente ver quanto falta.
- unix timestamp de quando reseta.
Onde aplicar: camadas de rate limit
| Camada | Exemplo | Quando usa |
|---|---|---|
| CDN/Edge | Cloudflare Rate Limit, Fastly, AWS WAF | DDoS protection, bots, muito barato pq bloqueia antes do origin |
| API Gateway | Kong, AWS API Gateway, Apigee, Traefik | Por API key/plan/tier, rotas diferentes com limites diferentes |
| Application (middleware) | Redis + Lua no app | Lógica complexa (quota por feature, limites dinâmicos) |
| Database/Storage | Redis connection pool, DB rate limit | Proteger backend caro (LLM API, gateway de pagamento) |
Camadas combinadas: na prática, você tem rate limit em múltiplas camadas. CDN bloqueia bot scrapers baratos; API gateway aplica plan limits; middleware faz rate limit fino por feature; e o DB pool protege contra self-DoS. Cada camada pega o que a anterior deixou passar.
Distribuindo rate limit: estratégias de precisão vs perf
| Estratégia | Precisão | Latência | Complexidade |
|---|---|---|---|
| Redis centralized (Lua) | Altíssima | +1-2ms por req | Média — só Redis |
| Redis Cluster com hash slot | Alta | +1-2ms | Alta — cluster |
| Local cache + periodic sync | Baixa (overshoot) | ~0ms | Alta — eventual consistency |
| Sticky routing + local | Alta (se sticky funciona) | ~0ms | Baixa |
| Global DynamoDB with conditional writes | Média-alta | +5-10ms | Média |
"Sloppy" rate limit é OK quando overshoot tolerável. Se limite é 1000/min e você tem 10 réplicas fazendo 100/min local, overshoot máximo é 10% (1100/min). Pra free tier com monetização por overage, totalmente aceitável. Pra limites críticos (LLM API pagando por token), centralize.
Decisões reais
📋 API pública com múltiplos planos (free, pro, enterprise), precisa ser preciso
Token bucket é amigável pra devs (permite burst), Lua garante atomicidade. API Gateway aplica limite antes da app, liberando CPU do backend. Config por plano via Redis HGET. Esse é o padrão Stripe/GitHub.
Alt: —
📋 Proteger backend caro de LLM API — 1 req custa $0.30, overshoot inaceitável
Aqui, +2ms de latência é negligível comparado ao risco de estourar budget. Use Lua script atômico. Se Redis cair, fail-closed (rejeita) — melhor do que abrir floodgate.
Alt: —
📋 Anti-DDoS em edge global, milhões de IPs, perf crítica
Zero round-trip por req. Cada worker tem token bucket local. Sincroniza agregado global a cada 100ms. Overshoot tolerável em anti-DDoS (você ainda bloqueou 99% do ataque). Cloudflare e Fastly operam assim.
Alt: —
📋 Gate de job queue: máximo 5 jobs concorrentes por cliente
Rate limiting é request/time; concurrency limit é active/pending. Use semáforo: ZSET com timestamps, expire ativos após N segundos. Pra precisão estrita, advisory locks no PG (1 por slot). SQS também tem visibility timeout que serve pra isso.
Alt: —
Perguntas típicas (Q&A)
Como fazer rate limit multi-dimensional (por key + por IP + global)?
Múltiplos buckets. Uma req chama Lua script que testa cada bucket em sequência. Se qualquer um estourar, rejeita. Ex: rate:key:abc (100/min), rate:ip:1.2.3.4 (1000/min), rate:global (10000/min).
O que fazer se Redis cair?
Decisão por business: fail-open (deixa passar, prioriza disponibilidade) ou fail-closed (rejeita tudo, prioriza proteção). APIs pagas geralmente fail-closed; APIs user-facing fail-open mas com circuit breaker. Tenha alerta agressivo em queda do Redis.
Rate limit por IP é bom?
Razoável pra bots, perigoso pra usuários legítimos (NAT, CG-NAT, corporate proxies, mobile carriers compartilham IPs entre milhares). Use como última camada + whitelist trusted ranges + combine com API key.
Como modelar "quota mensal" em vez de rate limit?
Contador em Redis com TTL até fim do mês, ou em Postgres row com UPDATE atômico. Rate limit protege bursts; quota protege volume total. Diferente, complementar.
Cliente bem-comportado recebe 429 às vezes — isso é normal?
Com rate limit preciso e limite justo, sim. SDK deve implementar retry com backoff (respeitando Retry-After). Se clientes reclamam, revise se o limite está calibrado ou se eles precisam de plano maior.
Take-aways:
Parabéns! Você terminou a trilha de Sistemas Distribuídos. Do CAP ao rate limit, você tem as bases pra projetar e operar sistemas que sobrevivem à realidade da rede.
Discussão
Carregando…