Limites de taxa
Quais rotas têm limite, as janelas e os números de cada uma, a resposta 429 RATE_LIMITED com Retry-After e como o contador funciona no Redis.
O limite protege as rotas públicas: as que mandam e-mail, as que chamam um verificador pago e as que só têm o token do link como credencial. As rotas com Bearer não têm limite de taxa hoje.
A resposta
HTTP/1.1 429 Too Many Requests
Retry-After: 2731
content-type: application/json
{ "success": false,
"error": { "code": "RATE_LIMITED", "message": "too many requests",
"details": { "retryAfterSeconds": 2731 } } }Retry-After e details.retryAfterSeconds sempre têm o mesmo valor: os segundos que faltam pra janela acabar. O header é posto pelo errorHandler sempre que o erro é um TooManyRequestsError.
Rotas com limite
Cada regra dá um orçamento pelo identificador (e-mail, link ou token) e outro por IP. As duas contam, e a primeira que estourar responde 429. O formato da variável é limit/windowSeconds.
Login
| Rota | Por identificador | Por IP |
|---|---|---|
POST /auth/otp/request | RATE_OTP_REQUEST_PER_EMAIL = 5/3600. Conta também endereço sem conta. Maiúsculas e espaços são ignorados | RATE_OTP_REQUEST_PER_IP = 20/3600 (balde mail) |
POST /auth/otp/resend | RATE_OTP_RESEND_PER_LINK = 3/3600, mais o orçamento do e-mail (o mesmo do request) | O mesmo balde mail |
POST /auth/otp/verify | O código trava depois de OTP_MAX_ATTEMPTS (5) erros (OTP_LOCKED, não 429) | RATE_OTP_VERIFY_PER_IP = 40/3600 |
Link de consentimento (por link e por IP)
| Rota | Por link | Por IP |
|---|---|---|
POST /consent-requests/{token}/evidence | RATE_EVIDENCE_PER_LINK = 10/3600 | RATE_EVIDENCE_PER_IP = 30/3600 |
…/decline e …/stop (um balde só) | RATE_CONSENT_WRITE_PER_LINK = 10/3600 | RATE_CONSENT_WRITE_PER_IP = 30/3600 |
GET /consent-requests/{token} e …/protection (um balde só) | RATE_CONSENT_READ_PER_LINK = 120/600 | RATE_CONSENT_READ_PER_IP = 600/600 |
Pareamento pelo celular da pessoa (números fixos no código, janela de 10 min)
| Rota | Por link | Por IP |
|---|---|---|
POST …/pairing | 10 | 30 |
POST …/pairing/passkey | 10 | 600 |
GET …/pairing | 120 | 600 |
GET …/events (SSE) | 30 | 600 |
As regras de uma hora lado a lado, com os padrões do env.ts:
- Por identificador (e-mail ou link)
- Por IP
| regra | Por identificador (e-mail ou link) | Por IP |
|---|---|---|
| Pedir código | 5 | 20 |
| Evidência | 10 | 30 |
| Recusar ou parar | 10 | 30 |
Padrões de backend/src/config/env.ts: RATE_OTP_REQUEST_PER_EMAIL/_PER_IP, RATE_EVIDENCE_PER_LINK/_PER_IP, RATE_CONSENT_WRITE_PER_LINK/_PER_IP.
Algumas escolhas:
- A evidência é limitada antes de ler o corpo. Um pedido recusado não custa o parse de um multipart grande.
- Nas rotas de consentimento, o limite roda antes de procurar o token. Testar tokens em sequência custa o mesmo que insistir num só.
- O orçamento por IP é mais folgado porque uma família costuma dividir o mesmo roteador.
Implementação
backend/src/infra/rate-limit.ts é uma janela fixa no Redis:
INCR ratelimit:<chave>;- no primeiro acerto da janela,
EXPIREcomwindowSeconds; - passou do
limit: lê oTTLe lançaTooManyRequestsError(ttl). Se a chave ficou sem expiração (queda entre oINCRe oEXPIRE), ela ganha uma ali mesmo, pra ninguém ficar travado pra sempre.
É grosseira de propósito: o que importa é "mais ou menos tantos por janela". As chaves nunca guardam segredo em claro. Usam o SHA-256 do e-mail ou do token (otp:request:email:<sha>, consent:read:link:<hash>, pairing:start:link:<hash>) e o IP (…:ip:<ip>).
import { rateLimit } from "@infra/rate-limit.ts";
import { clientIp } from "../../request-meta.ts";
await rateLimit(`minha-rota:link:${hashToken(token)}`, {
limit: 10,
windowSeconds: 600,
});
await rateLimit(`minha-rota:ip:${clientIp(c)}`, {
limit: 30,
windowSeconds: 600,
});
// passou do limite → 429 RATE_LIMITED + Retry-After, pelo errorHandlerNo cliente, use retryAfterSeconds pra mostrar a espera ("Tente de novo em N min") e pra controlar o botão de reenviar.
Ver o trecho de um alerta (POST /alerts/{uid}/excerpt) tem limite por responsável: 30 por minuto, chave alerts:excerpt:user:{id}. É a leitura mais sensível do sistema; o 429 traz o mesmo retryAfterSeconds.
Em aberto
clientIp()confia na primeira entrada doX-Forwarded-For. Isso só está certo se o proxy de borda (Traefik no Dokploy) descarta o header que o cliente manda. Seapi.superdinda.comficar atrás da Cloudflare, os baldes por IP passam a juntar todo mundo nos IPs dela, e o header pode ser forjado. Falta conferir a cadeia de produção.
Fontes
backend/src/infra/rate-limit.ts,backend/src/http/request-meta.tsbackend/src/http/routes/auth/limits.ts,backend/src/domains/auth/use-cases/request-otp.ts,resend-link.ts,code-lock.tsbackend/src/http/routes/consents/limits.ts,pairing.route.ts,page.route.ts,evidence.route.ts,decline.route.ts,protection.route.tsbackend/src/http/middleware/error-handler.ts,backend/src/core/contracts/errors.tsbackend/src/config/env.ts,backend/.env.example- Notas das frentes k-otp e k-pairing (
k-otp-api.md,k-pairing-api.md) .specs/features/redesign/api-gaps.md(G-04, G-14)
