Rate Limiting Distribuído: token bucket, sliding window, Redis
- ⬜🔁 Idempotência e Retries: o antídoto pra rede que quebra(Sistemas Distribuídos)
Recomendamos completar os pré-requisitos antes de seguir, mas nada te impede de continuar.
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ássicosde 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
- Centralized: estado único em Redis/Memcached. Cada req faz round-trip. Simples, preciso, latência extra.
- Decentralized / sloppy: cada réplica tem estado local, sincroniza periodicamente. Rápido mas impreciso — tolera overshoot.
Stripe, GitHub e Cloudflare usam híbrido: Redis central pra limites que importam, local approximate pra limites soft. Cada um afina pra seu caso.
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
Limite: 100 req/minuto
Janela: minuto atual (ex: 14:23:00-14:23:59)
Cliente faz req:
1. key = "rate:${client_id}:14:23" (truncado ao minuto)
2. n = INCR key
3. se primeira vez: EXPIRE key 60s
4. se n > 100: reject
5. else: allow
Pro: simplíssimo
Contra: boundary bug — 100 reqs em 14:23:59 + 100 em 14:24:01 = 200 em 2s# 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 adicionadoSliding 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.
Limite: 100/min Janela anterior (14:23): 80 reqs Janela atual (14:24): 50 reqs Time: 14:24:30 → 50% da janela atual passou Estimativa deslizante: = (reqs janela anterior * % remanescente anterior) + reqs janela atual = 80 * 0.5 + 50 = 90 90 < 100 → ALLOW Janela anterior "contribui" menos conforme avança o tempo
# 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 TrueToken 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.
Capacidade C = 10 tokens Refill R = 5 tokens/s Balde inicial: cheio (10) t=0s: requisição → toma 1 token → balde = 9 t=0.1: 5 requisições rápidas → balde = 4 (burst!) t=0.5: requisição → balde = 3, mas refill: +2.5 = 5.5 → 5 (cap inteiro) t=1s: requisição → balde = 4, refill: +2.5 = 6.5 → 6 ... Cliente bem-comportado: sempre tem tokens Cliente em burst controlado: funciona até estourar Cliente em loop infinito: pega C, esvazia, fica bloqueado aguardando refill
-- 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), remainingLeaky 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 TrueRate 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.
Retry-Afterem segundos — SDKs usam isso pra backoff.X-RateLimit-Remainingem todas as respostas (200 e 429) pra cliente ver quanto falta.X-RateLimit-Resetunix 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) |
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 |
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: Sliding window counter — Se burst não é desejável (controle mais apertado).
📋 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: Sloppy local sync — NÃO — 10% overshoot = $300 a mais/1000 reqs. Não vale.
📋 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: Centralized — Latência inaceitável pro tráfego de edge. Não escala.
📋 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: Token bucket — Não serve — não limita paralelismo, só taxa.
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.
- Token bucket é o padrão pra APIs públicas — amigável, permite burst.
- Sliding window counter é o sweet spot precisão/memória — usado pela Cloudflare.
- Fixed window tem bug de boundary (até 2x no limite). OK pra limites soft.
- Redis + Lua dá atomicidade sem race condition. Preferir EVALSHA + SHA cached.
- Retornar 429 + Retry-After + X-RateLimit-* — SDKs dependem disso pra backoff.
- Rate limit em múltiplas camadas: CDN → gateway → app → backend. Cada uma protege a próxima.
- Centralized quando precisão importa (LLM APIs caras); sloppy local quando perf importa (anti-DDoS edge).
- Fail-open vs fail-closed é decisão de negócio — documentar e instrumentar.
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.
Quiz rápido
4 perguntas · Acerte tudo e ganhe o badge 🎯 Gabarito