Skip to content
This page has been auto-translated and may contain errors.View in English

Construindo um rate limiter

Um endpoint, e um script que o acerta quinze vezes seguidas.

js
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.

js
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:

js
req.rateLimit  // { limit, remaining, reset, used }

Útil para depuração, e o lugar errado para um cliente ler essa informação.

JunoColocando um limiter na frente A posição do middleware é o design inteiro, e é o mesmo esquema do capítulo de validação: algo fica na frente, e o handler só roda se aquilo passar.

Você não muda o handler em nada. Você muda o que precisa acontecer antes dele.

JunoColocando um limiter na frente Vale a pena anexar o limiter por rota, e não no app inteiro, de forma deliberada. Um endpoint de login e um endpoint de leitura de dados querem orçamentos bem diferentes, e um limiter global único dá o mesmo orçamento para os dois.

O formato mais comum é um limiter generoso para o app inteiro, mais limiters nomeados e apertados nos poucos endpoints que precisam deles.

JunoColocando um limiter na frente O armazenamento padrão é memória no próprio processo, o que significa que cada instância conta separadamente e seu limite efetivo se multiplica pela quantidade de instâncias que você roda. Duas instâncias atrás de um load balancer transformam um limite de 5 em 10.

Corrigir isso exige um armazenamento compartilhado, e a versão ingênua com Redis tem uma race condition. Um contador de ler-decidir-escrever, testado com 400 requisições concorrentes contra um limite de 100, deixou passar todas as 400.

A resposta é tornar a checagem atômica, com um incremento atômico ou um script Lua, já que o Redis executa um script até o final sem interrupção.

Também vale conhecer passOnStoreError, que decide o que acontece quando o armazenamento fica inacessível. O padrão é false, então o tráfego é bloqueado e uma queda do Redis vira uma queda para você também.

Se o certo é isso ou deixar aberto por padrão depende inteiramente do que o endpoint faz.

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í:

EraHeaders
LegadoX-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
Padrão, desde por volta de 2021RateLimit-Limit, RateLimit-Policy, RateLimit-Remaining, RateLimit-Reset

O pacote ainda usa o conjunto legado por padrão, então peça o moderno explicitamente:

js
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:

http
RateLimit-Limit: 5
RateLimit-Policy: 5;w=60
RateLimit-Remaining: 4
RateLimit-Reset: 60

Com isso em vigor, req.rateLimit some do corpo do JSON. Os headers fazem esse trabalho como deve ser.

JunoMostrando ao cliente onde ele está Os headers chegam em toda resposta, não só nas recusas. É isso que os torna úteis.

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.

JunoMostrando ao cliente onde ele está Preste atenção nas unidades do Reset, porque os dois formatos discordam. Verificado na versão 8.7.0: o legado X-RateLimit-Reset é um timestamp Unix, enquanto o RateLimit-Reset do draft-6 é segundos restantes.

Ler um como se fosse o outro te dá uma espera de uns cinquenta anos ou uma nova tentativa que dispara imediatamente, e os dois parecem um bug do cliente em vez de um bug de unidades.

JunoMostrando ao cliente onde ele está O padrão evoluiu desde o draft-6 e o pacote acompanha isso. Os drafts 7 e 8 substituem os campos separados por um único header combinado, e a versão 8 aceita 'draft-6', 'draft-7' ou 'draft-8' onde versões antigas aceitavam um booleano. Passar true ainda funciona e significa draft-6.

O draft-8 não se parece em nada com os outros, verificado na 8.7.0:

RateLimit: "3-in-10sec"; r=2; t=10

Política nomeada, restante, e tempo até o reset, tudo em um único campo estruturado. Vale a pena conhecer isso antes de escrever um cliente que analisa esses valores, e vale a pena fixar o draft explicitamente para que um padrão futuro não mude isso debaixo dos seus pés.

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çõesResultado
1 a 3200, com Remaining contando 2, 1, 0
4 e 5429, Reset contando regressivamente
6 a 8200 de novo, a janela resetou
9 e 10429

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.

JunoObservando o reset acontecer Desacelerar o cliente de teste é o truque que vale a pena guardar. Disparado a toda velocidade, tudo acontece dentro de uma única janela e o limiter parece só um interruptor liga-desliga.

Dois segundos entre requisições e você consegue observar o reset acontecendo, que é a parte que realmente explica o comportamento.

JunoObservando o reset acontecer Teste com o cliente real, não recarregando o navegador. Um navegador envia suas próprias requisições extras para ícones e outros recursos, cada uma consumindo orçamento que você não pretendia gastar, o que deixa os números confusos antes mesmo de você entendê-los.

Um scriptzinho que dispara um número conhecido de requisições em um intervalo conhecido é a ferramenta que torna o comportamento do limiter legível.

JunoObservando o reset acontecer Limites apertados como 3 a cada 10 segundos servem para enxergar o comportamento, nunca para produção. Números reais vêm de medir o que clientes legítimos fazem, e depois deixar uma folga acima do mais intenso deles.

Coloque um limiter em modo de monitoramento primeiro, se puder: conte e registre o que teria sido recusado, sem recusar de fato.

Uma semana disso te diz se o seu número é protetor ou uma queda que você mesmo agendou. Muito mais barato do que descobrir por meio de uma fila de suporte.

Coloque em prática

Uma API é limitada a 100 requisições por minuto, e clientes continuam relatando que requisições falham sem aviso.

js
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.