Executando verificação de segurança...
1

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

CamadaQuemTetoJanelaChave Redis
DiárioFree logado200/dia24h (reset BRT)rl:day:{user_id}:{YYYYMMDD}
DiárioPro logado5.000/dia24h (reset BRT)rl:day:{user_id}:{YYYYMMDD}
BurstAnônimo10/min60s + cooldown 10srl:cnt:anon:{ip} + rl:lock:anon:{ip}
BurstFree logado40/min60s + cooldown 10srl:cnt:free:{ip} + rl:lock:free:{ip}
Brute-forceLogin/register10/min por IP60srl: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:

  1. X-API-Key no header → usuário da API.
  2. Authorization: Bearer {jwt} → usuário web logado.
  3. 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.

Carregando publicação patrocinada...