🧠FFVAcademy
🚦

Rate Limiting Distribuído: token bucket, sliding window, Redis

15 min de leitura·+75 XP
Pré-requisitos (0/1)0%

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
💡
Duas abordagens:
  • 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

AlgoritmoIntuiçãoPermite burst?MemóriaPrecisão
Fixed Window CounterContador por janela (ex: 00:00-00:59)Sim (mas em boundary — 2x no limite)O(1) por clienteBaixa no boundary
Sliding Window LogGuarda timestamp de cada reqConforme limiteO(N) — N reqs na janelaMáxima
Sliding Window CounterInterpola entre 2 janelas fixasSuaveO(1)Alta (aproximação ótima)
Token BucketBalde com capacidade C, refill R/segSim (até C tokens)O(1)Alta
Leaky BucketFila FIFO com saída constanteNão (saída suave)O(N) da filaAlta

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
python
# 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 <= limit

Sliding 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.

python
# 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 adicionado
⚠️
Tradeoff: 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.

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
python
# 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 True
💡
Precisã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.

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
lua
-- 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}
python
# 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), remaining
💡
Por 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).

python
# 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 True
⚠️
Token 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
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-After em segundos — SDKs usam isso pra backoff.
  • X-RateLimit-Remaining em todas as respostas (200 e 429) pra cliente ver quanto falta.
  • X-RateLimit-Reset unix timestamp de quando reseta.

Onde aplicar: camadas de rate limit

CamadaExemploQuando usa
CDN/EdgeCloudflare Rate Limit, Fastly, AWS WAFDDoS protection, bots, muito barato pq bloqueia antes do origin
API GatewayKong, AWS API Gateway, Apigee, TraefikPor API key/plan/tier, rotas diferentes com limites diferentes
Application (middleware)Redis + Lua no appLógica complexa (quota por feature, limites dinâmicos)
Database/StorageRedis connection pool, DB rate limitProteger 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égiaPrecisãoLatênciaComplexidade
Redis centralized (Lua)Altíssima+1-2ms por reqMédia — só Redis
Redis Cluster com hash slotAlta+1-2msAlta — cluster
Local cache + periodic syncBaixa (overshoot)~0msAlta — eventual consistency
Sticky routing + localAlta (se sticky funciona)~0msBaixa
Global DynamoDB with conditional writesMédia-alta+5-10msMé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 + Redis Lua + API Gateway

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 counterSe burst não é desejável (controle mais apertado).

📋 Proteger backend caro de LLM API — 1 req custa $0.30, overshoot inaceitável

Redis centralized, aceitar +2ms, errar pelo lado conservador

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 syncNÃO — 10% overshoot = $300 a mais/1000 reqs. Não vale.

📋 Anti-DDoS em edge global, milhões de IPs, perf crítica

Local rate limit por worker + global sync assíncrono

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: CentralizedLatência inaceitável pro tráfego de edge. Não escala.

📋 Gate de job queue: máximo 5 jobs concorrentes por cliente

Semáforo distribuído (Redis ZSET ou advisory lock por slot)

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 bucketNã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.

Take-aways:
  • 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

Continue lendo