Blog

Rate limiting: token bucket, sliding window e o que usar quando.

Rate limit parece configuração e é decisão de arquitetura. O algoritmo escolhido determina o que acontece quando o tráfego chega em rajada, e as diferenças aparecem justamente no momento em que você mais depende do limite funcionar.

Janela fixa: simples e com um defeito grave

Conta requisições por janela de tempo. Ao virar a janela, o contador zera.

minuto 10: [====      ] 40/100
minuto 11: [          ]  0/100   <- zera

Fácil de implementar, barato de guardar, e tem um problema conhecido: a rajada na fronteira.

Com limite de 100 por minuto, um cliente manda 100 requisições às 10:59:59 e outras 100 às 11:00:01. São 200 em dois segundos, e as duas janelas respeitaram o limite.

Se o motivo do limite é proteger um recurso escasso, esse dobro chega exatamente no pior formato: concentrado.

Janela deslizante: correta e mais cara

Conta as requisições dos últimos N segundos a partir de agora, não de uma janela alinhada.

// ZSET no Redis: score é o timestamp
await redis.zremrangebyscore(key, 0, now - windowMs);
const count = await redis.zcard(key);
if (count >= limit) throw new TooManyRequestsException();
await redis.zadd(key, now, `${now}:${randomUUID()}`);
await redis.pexpire(key, windowMs);

Não tem o problema da fronteira. Custa memória proporcional ao número de requisições na janela, o que importa quando o limite é alto.

Existe a variante aproximada, que interpola entre a janela atual e a anterior. Guarda dois contadores em vez de N timestamps, e o erro é pequeno. Para a maioria dos casos, é o melhor equilíbrio.

Token bucket: o que eu uso na maioria das vezes

Um balde com capacidade máxima, que enche a uma taxa constante. Cada requisição consome um token.

capacidade: 100 tokens
recarga: 10 tokens por segundo

O que isso permite: uma rajada de até 100 requisições de uma vez, seguida de um regime sustentado de 10 por segundo.

Essa é a propriedade que faz ele ser o mais usado. Cliente real não manda tráfego uniforme: manda em rajada, porque abriu uma tela que dispara seis chamadas. Token bucket acomoda a rajada legítima e continua limitando o abuso sustentado.

-- Redis, atômico: capacidade e taxa em KEYS/ARGV
local tokens = tonumber(redis.call("HGET", KEYS[1], "tokens") or ARGV[1])
local last   = tonumber(redis.call("HGET", KEYS[1], "ts") or ARGV[4])
local delta  = math.max(0, ARGV[4] - last) / 1000
tokens = math.min(tonumber(ARGV[1]), tokens + delta * tonumber(ARGV[2]))

if tokens < 1 then return 0 end

redis.call("HSET", KEYS[1], "tokens", tokens - 1, "ts", ARGV[4])
redis.call("PEXPIRE", KEYS[1], ARGV[3])
return 1

Precisa ser atômico. Ler, calcular e escrever em três comandos separados tem corrida, e sob carga alta essa corrida é justamente quando o limite deveria estar funcionando.

Leaky bucket: quando a saída precisa ser constante

Fila com vazão fixa. Requisição entra na fila, sai a uma taxa constante. Se a fila enche, rejeita.

Diferente do token bucket, ele não permite rajada na saída. Serve quando o recurso protegido não aguenta pico de jeito nenhum: uma API de terceiro com limite rígido, um sistema legado, um processo com concorrência limitada.

O custo é latência: a requisição espera na fila em vez de ser rejeitada na hora.

A decisão que importa mais que o algoritmo

Por qual chave você limita.

Por IP é o padrão e é o mais fraco. Uma empresa inteira atrás de um NAT compartilha o IP, e você bloqueia gente legítima. IPv6 é abundante e barato pro atacante. E proxy mal configurado faz todo mundo parecer o mesmo IP, ou pior, faz o cabeçalho X-Forwarded-For ser forjável.

Por usuário autenticado é bem melhor, e não protege o endpoint de login, que é público por definição.

Por conta ou tenant é o que faz sentido em API de negócio, e é o que permite ter limites diferentes por plano.

A combinação que eu uso: limite por IP frouxo como rede de segurança, limite por identidade apertado onde existe identidade, e limite específico e bem baixo nos endpoints sensíveis, como login, envio de código e recuperação de senha. Escrevi sobre esse último caso em como construir aplicações seguras.

Responda direito

429 Too Many Requests, com cabeçalhos que dizem o que está acontecendo:

HTTP/1.1 429 Too Many Requests
Retry-After: 30
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 30

Sem Retry-After, o cliente bem-intencionado retenta imediatamente e piora tudo. Com ele, uma biblioteca decente espera o tempo indicado.

E devolva os cabeçalhos também nas respostas de sucesso. Assim o cliente sabe que está chegando perto antes de bater, e pode se ajustar sozinho.

O erro que transforma proteção em queda

Rate limit que depende de Redis e não trata a indisponibilidade dele.

Se o Redis cair e o código lançar exceção, todas as requisições falham. O mecanismo que existia pra proteger o sistema derrubou o sistema.

A decisão precisa ser explícita: falhar aberto, deixando passar quando o Redis não responde, ou falhar fechado, bloqueando.

Para API de negócio, falhar aberto costuma ser certo: pior aceitar tráfego demais por dez minutos do que ficar fora. Para endpoint de login, falhar fechado pode ser preferível, porque a alternativa é uma janela sem proteção contra força bruta.

Escolha, escreva a escolha, e teste desligando o Redis em homologação. Esse teste quase nunca é feito, e é onde mora o incidente.

← Voltar pro blog