Construindo um rate limiter
Um endpoint, e um script que o acerta quinze vezes seguidas.
app.get('/api/data', (req, res) => {
res.json({ timestamp: new Date().toISOString() })
})Quinze requisições, quinze 200s. Nada está contando.
Colocando um limiter na frente
O express-rate-limit é middleware, então ele fica entre a rota e o handler e decide se o handler roda ou não.
import { rateLimit } from 'express-rate-limit'
const limiter = rateLimit({
limit: 5,
windowMs: 60000,
message: 'Too many requests, please try again later.',
})
app.get('/api/data', limiter, (req, res) => {
res.json({ timestamp: new Date().toISOString() })
})Três configurações: quantas requisições, em qual janela de tempo em milissegundos, e o que dizer ao recusar. Rode as quinze requisições de novo e as cinco primeiras voltam 200, e o resto vem 429 Too Many Requests.
Isso não é exatamente uma fixed window pura
No algoritmo, as janelas avançam num cronograma fixo, cheguem requisições ou não. Este pacote documenta windowMs como uma janela que começa na primeira requisição do cliente, e reseta a contagem dele quando o tempo passa.
Chame isso de janela de início preguiçoso. O burst na fronteira continua existindo, só que agora a fronteira é onde quer que cada cliente tenha começado, em vez de um ponto compartilhado no relógio.
O middleware também anexa seu estado à requisição:
req.rateLimit // { limit, remaining, reset, used }Útil para depuração, e o lugar errado para um cliente ler essa informação.
Você não muda o handler em nada. Você muda o que precisa acontecer antes dele.
Mostrando ao cliente onde ele está
Informação de rate limit pertence aos headers. O JSON é para o que a aplicação retorna; os headers são para informações sobre a própria troca, junto com o content type e os códigos de status.
O argumento prático é mais forte que o argumento de organização. Sem headers, um cliente descobre o limite ao esbarrar nele. Com eles, toda resposta diz quanto orçamento ainda resta, então um cliente bem escrito diminui o ritmo antes de ser recusado.
Isso importa para APIs pagas, onde uma requisição desperdiçada custa dinheiro, e torna a depuração possível sem precisar analisar corpos de resposta.
Existem dois formatos, e ambos aparecem por aí:
| Era | Headers |
|---|---|
| Legado | X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset |
| Padrão, desde por volta de 2021 | RateLimit-Limit, RateLimit-Policy, RateLimit-Remaining, RateLimit-Reset |
O pacote ainda usa o conjunto legado por padrão, então peça o moderno explicitamente:
const limiter = rateLimit({
limit: 5,
windowMs: 60000,
message: 'Too many requests, please try again later.',
standardHeaders: 'draft-6',
legacyHeaders: false,
})Agora toda resposta carrega a posição do cliente:
RateLimit-Limit: 5
RateLimit-Policy: 5;w=60
RateLimit-Remaining: 4
RateLimit-Reset: 60Com isso em vigor, req.rateLimit some do corpo do JSON. Os headers fazem esse trabalho como deve ser.
Um cliente pode observar o Remaining cair e diminuir o ritmo antes de chegar a zero, em vez de descobrir a parede batendo de frente nela.
Observando o reset acontecer
Quinze requisições disparadas dentro de um segundo caem todas na mesma janela, então você vê cinco sucessos e dez recusas e nunca vê um reset.
Diminua a janela e desacelere o cliente, e o ciclo fica visível. Um limite de 3 a cada 10 segundos, com 2 segundos entre requisições:
| Requisições | Resultado |
|---|---|
| 1 a 3 | 200, com Remaining contando 2, 1, 0 |
| 4 e 5 | 429, Reset contando regressivamente |
| 6 a 8 | 200 de novo, a janela resetou |
| 9 e 10 | 429 |
Esse é o comportamento inteiro do algoritmo em uma única execução: um burst permitido, uma parede, um reset, outro burst. O que também deixa visível o problema da fronteira, já que três requisições logo antes de um reset e três logo depois deixam passar seis de uma vez.
Dois segundos entre requisições e você consegue observar o reset acontecendo, que é a parte que realmente explica o comportamento.
Coloque em prática
Uma API é limitada a 100 requisições por minuto, e clientes continuam relatando que requisições falham sem aviso.
const limiter = rateLimit({ limit: 100, windowMs: 60000 })
app.use(limiter)
app.get('/api/data', (req, res) => {
res.json({ data, rateLimit: req.rateLimit })
})Encontre três problemas.
Compare suas respostas
1. Sem headers padrão. Com standardHeaders não definido, o pacote emite apenas o conjunto legado X-RateLimit-*, então um cliente seguindo o padrão moderno não vê nada e não consegue saber que está se aproximando do limite. Defina standardHeaders: 'draft-6' e legacyHeaders: false.
2. Estado do rate limit no corpo do JSON. req.rateLimit na resposta funciona para esse endpoint e para nenhum outro lugar. Um 429 retorna o corpo do erro, não esse, então a informação desaparece justamente quando é necessária. Os headers chegam em toda resposta, inclusive nas recusas.
3. app.use(limiter) aplica um único orçamento a tudo. Uma tentativa de login e uma leitura de dados compartilham o mesmo 100, então a navegação comum esgota o orçamento que deveria proteger o endpoint de login.
Anexe limiters por rota, com números mais apertados nos sensíveis.
Há um quarto problema que vale notar se a API roda em mais de uma instância: o armazenamento padrão é no próprio processo, então cada instância conta separadamente e o limite real é 100 vezes o número de instâncias.
Para onde isso vai a seguir
O limiter funciona, e toda requisição que ele conta é atribuída a quem quer que o pacote decida que a fez. Até agora, isso tem sido o endereço IP do cliente, escolhido por padrão, não por você.
Identificando clientes torna essa decisão explícita, porque aquilo contra o que um limite conta muda quem ele protege e quem ele pune.

