Rate limit em 3 camadas com Redis: diário, rajada e brute-force (FastAPI)
Seguindo a saga de descobrimentos duarante uma implementação caótica.
No CNPJ Aberto não usamos um rate limit, usamos três, cada um resolve um problema diferente, com chave diferente e janela diferente. Quando estava tratando tudo como "um contador só", ou sobrava brecha pra scraper ou bloqueava usuário real.
Quem entra em cada camada
| Camada | Quem | Teto | Janela | Chave Redis |
|---|---|---|---|---|
| Diário | Free logado | 200/dia | 24h (reset BRT) | rl:day:{user_id}:{YYYYMMDD} |
| Diário | Pro logado | 5.000/dia | 24h (reset BRT) | rl:day:{user_id}:{YYYYMMDD} |
| Burst | Anônimo | 10/min | 60s + cooldown 10s | rl:cnt:anon:{ip} + rl:lock:anon:{ip} |
| Burst | Free logado | 40/min | 60s + cooldown 10s | rl:cnt:free:{ip} + rl:lock:free:{ip} |
| Brute-force | Login/register | 10/min por IP | 60s | rl:auth:{ip}:{YYYYMMDDHHMM} |
Anônimo não entra no diário, se entrasse, qualquer um limparia minha cota com IP rotativo. Pro não entra no burst, cliente pagante pode fazer pico de navegação sem engasgar.
Só certos paths contam
THROTTLED_PREFIXES = (
"/api/cnpj/", "/api/busca-avancada",
"/api/socios/busca", "/api/socios-comum",
)
_DAILY_CAP_EXEMPT = (
"/api/auth/", "/api/payment/", "/api/admin/",
"/api/health", "/api/stats", "/api/panorama",
"/api/cnaes", "/api/municipios",
)
Autocomplete de CNAE e analytics de pageview não consomem cota — punir o usuário por navegar seria tiro no pé. Tudo que é agregado estatístico público já é cacheado 7+ dias, então também fica fora.
Núcleo atômico (o de sempre)
new_val = r.incr(key)
if new_val == 1:
r.expire(key, 86400) # só quando a chave nasce
if new_val > limit:
raise HTTPException(429, ...)
O detalhe que mata: se eu chamar EXPIRE a cada INCR, o TTL se renova pra sempre e a cota nunca zera. Só expire quando retornar 1.
Burst tem cooldown explícito, não só janela
Quando o free estoura 40/min, eu não só recuso as próximas, crio rl:lock:free:{ip} com TTL de 10s e deleto o contador. Durante esses 10s, todo request cai no lock sem sequer tocar o contador. Isso barra o comportamento típico de scraper hobbyista (while true: curl ...) sem pênalti longo pro humano real que abriu 8 abas.
Resolução do "sujeito" da cota
Ordem de checagem:
X-API-Keyno header → usuário da API.Authorization: Bearer {jwt}→ usuário web logado.- Nem um nem outro → anônimo (cai só no burst).
Plano do usuário fica em uplan:{id} por 300s no Redis — pra não ter round-trip no Postgres a cada request. Quando o webhook de pagamento confirma upgrade, eu deleto essa chave na mão e o próximo request já trata como Pro.
Bypass pro SSR do Next
Meu Next.js faz fetch server-side no backend com header X-Internal-Secret. Todos esses requests saem do mesmo IP (container). Se o burst aplicasse aí, qualquer rajada de revalidação ISR detonaria a cota de todos os outros usuários que estivessem atrás daquele edge.
Solução:
if (settings.internal_secret
and request.headers.get("x-internal-secret") == settings.internal_secret
and path.startswith("/api/cnpj/")):
return plan # pula burst, diário continua valendo pro user real
Observabilidade: 429 vira linha no Postgres
Todo bloqueio (burst ou diário) vira um ThrottleEvent com user_id, plan, path, visitor_id e timestamp. No admin eu filtro por IP ruidoso, por path mais atingido, e por tier que está batendo cota com mais frequência — é esse dado que mexe os números dos planos pra cima ou pra baixo, nunca feeling.