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

Throttling

Um atendente no topo de um tobogã aquático. Ele não manda as crianças embora, ele faz cada uma esperar um pouco mais que a anterior.

Throttling deixa as requisições mais lentas depois que uma certa quantidade já passou, em vez de recusá-las ao atingir um limite rígido. O cliente ainda recebe uma resposta, só que mais tarde.

Um atraso em vez de uma recusa

express-slow-down é um middleware, com o mesmo formato do limiter: ele conta as requisições em uma janela de tempo e começa a atrasá-las depois de ultrapassar um limite.

js
import { slowDown } from 'express-slow-down'

const throttle = slowDown({
  windowMs: 10000,
  delayAfter: 2,
  delayMs: () => 5000,
})

app.get('/api/data', throttle, handler)

Três configurações: a janela, quantas passam sem serem afetadas, e por quanto tempo tudo é atrasado depois disso.

Dispare cinco requisições de uma vez e as duas primeiras retornam imediatamente. As outras três esperam cinco segundos cada, e então são bem-sucedidas.

RequisiçãoChegouAtrasoRespondeu
10snenhum0s
20snenhum0s
30s5s5s
40s5s5s
50s5s5s

Nada foi recusado. Todas as requisições foram bem-sucedidas, e três delas demoraram cinco segundos a mais do que gostariam.

JunoUm atraso em vez de uma recusa O cliente nunca fica sabendo que foi throttled. Não há erro nem código de status para verificar, apenas uma resposta que demorou mais.

É esse o atrativo e também a armadilha. Nada quebra, mas nada avisa o cliente para diminuir o ritmo também.

JunoUm atraso em vez de uma recusa Uma requisição atrasada é uma conexão mantida aberta, então o throttling custa recursos justamente onde a recusa os libera. Alguns segundos a mais em cada requisição de uma rajada é tranquilo; um atraso longo aplicado de forma ampla é uma forma de esgotar seu próprio pool de conexões.

Esse é o argumento para combiná-lo com um limite rígido em vez de usá-lo sozinho, e para manter os atrasos na casa de poucos segundos.

JunoUm atraso em vez de uma recusa Verificado no express-slow-down 3.1.1: delayMs recebe uma função, enquanto versões mais antigas aceitavam um número simples. Passar um número agora gera um erro de configuração em vez de um fallback silencioso, e esse é exatamente o tipo de quebra de atualização que justifica fixar versões.

O motivo mais profundo para preferir o throttling com clientes bem-comportados é que ele aplica backpressure sem precisar da cooperação do cliente. Um 429 só ajuda se quem chama implementa backoff; um atraso o deixa mais lento independentemente de ter escrito esse código ou não.

Contra um atacante deliberado ele é mais fraco, já que esse atacante pode abrir mais conexões e não se importa em esperar. Use throttle para moldar o tráfego comum, e recuse para conter abuso.

Com que velocidade o atraso cresce

delayMs é uma função de hits, o número de requisições contadas na janela, então o atraso pode crescer junto com a pressão.

js
delayMs: () => 5000                              // fixo: sempre 5s
delayMs: (hits) => (hits - 2) * 1000             // incremental: 1s, 2s, 3s
delayMs: (hits) => hits * hits * 1000            // exponencial: 9s, 16s, 25s

Com delayAfter: 2, os três formatos se comportam de maneira bem diferente na quinta requisição:

HitFixoIncrementalExponencial
35s1s9s
45s2s16s
55s3s25s

O incremental subtrai delayAfter, então a primeira requisição atrasada espera um segundo em vez de três. O exponencial eleva a contagem de hits ao quadrado, então ele sobe de forma tão acentuada que uma pressão sustentada rapidamente se torna dolorosa.

JunoCom que velocidade o atraso cresce Olhe para a quinta requisição nas três colunas: três segundos, ou vinte e cinco. Mesmo middleware, mesmo limite.

A curva que você escolhe importa mais do que o limite em si, e isso só fica claro quando você compara os números lado a lado.

JunoCom que velocidade o atraso cresce O incremental é o padrão sensato. É suave com um cliente que ultrapassou o limite momentaneamente e firme com um que continua insistindo, e os atrasos ficam dentro de uma faixa que o timeout de quem chama vai tolerar.

O exponencial precisa de um teto. No hit 20, a versão ao quadrado pede 400 segundos, o que está bem além de qualquer timeout de cliente, então a requisição é abandonada enquanto seu servidor continua segurando a conexão por causa dela. O maxDelayMs é o que limita isso.

JunoCom que velocidade o atraso cresce Verificado na versão 3.1.1, e vale saber antes de escrever um teste: requisições concorrentes não terminam na ordem de chegada. Cinco disparadas de uma vez com um atraso incremental retornaram a requisição 4 em um segundo e a requisição 3 em dois, porque os números de hit são atribuídos na ordem em que o middleware os conta, não na ordem em que você as enviou.

Então a posição de uma requisição atrasada é decidida pelo número de hit que ela recebeu, e qualquer teste que faça asserções sobre a ordem de chegada vai falhar de forma intermitente por motivos que não têm nada a ver com o seu código.

Também vale a pena combinar o throttling com os headers RateLimit do limiter ao lado dele. Um atraso sozinho não dá ao cliente nenhum sinal sobre o qual agir, então um chamador bem escrito não tem em cima de que recuar.

O que a janela conta

express-slow-down usa uma janela deslizante. Cada requisição olha para trás windowMs e conta os hits dentro desse período, e se nada chegou por mais tempo do que isso, a contagem recomeça do zero.

O detalhe sutil é o que conta como estar dentro da janela: as requisições são contadas pelo momento em que chegaram, não pelo momento em que foram processadas.

Com windowMs: 10000 e delayAfter: 2, cinco requisições chegando de uma vez e mais cinco aos 30 segundos:

  • As cinco primeiras chegam todas em 0s. Duas passam, três são atrasadas e respondem depois.
  • A segunda rajada chega em 30s. Olhando dez segundos para trás cobre de 20s a 30s, e nada chegou nesse intervalo, mesmo que respostas atrasadas da primeira rajada ainda estivessem sendo enviadas.
  • Então a contagem recomeça do zero, duas passam, e três são atrasadas de novo.

Respostas atrasadas saindo do servidor não estendem a janela. Só chegadas novas fazem isso.

JunoO que a janela conta O atendente do tobogã aquático pensa nos últimos dez segundos e pergunta quem apareceu nesse tempo, não quem ainda está esperando parado pelo próprio cronômetro.

Aquelas crianças esperando já foram contadas quando chegaram. Contá-las de novo seria cobrar duas vezes pela mesma visita.

JunoO que a janela conta É isso que permite que clientes com tráfego em rajadas continuem funcionando. Gasta a rajada, espera a janela passar, gasta outra. Os atrasos nunca se acumulam entre rajadas, porque a janela já esqueceu a anterior.

Se não é isso que você quer, a janela precisa ser mais longa que o intervalo entre as rajadas, ou um limite rígido precisa ficar atrás do throttle.

JunoO que a janela conta Contar pelo momento de chegada é a escolha certa, e ela realmente cria uma interação estranha com um limiter posicionado depois. Uma requisição atrasada em dezesseis segundos é contada pelo throttle no momento em que chega e pelo limiter no momento em que é processada, então os dois middlewares estão medindo a mesma requisição em momentos diferentes.

Isso produz a reordenação da próxima seção, em que uma requisição mais recente ultrapassa uma mais antiga porque a mais antiga ainda está aguardando seu cronômetro.

Vale lembrar disso ao ler logs: as contagens do throttle e as do limiter vão divergir, e nenhuma das duas está errada.

Combinando um throttle e um limiter

Os dois se combinam, e a ordem importa porque o Express executa os middlewares da esquerda para a direita:

js
app.get('/api/data', throttle, limiter, handler)

Colocar o throttle primeiro significa que as requisições vão ficando progressivamente mais lentas, e só depois de um teto rígido é que o limiter as recusa. Inverta a ordem e o limiter corta os clientes antes que o throttle chegue a ser gentil com eles.

Um briefing prático do curso. O sistema se degrada depois de três requisições em dez segundos, então atrase a quarta em 1s, a quinta em 4s, a sexta em 9s. E não mais que sete requisições em trinta segundos, no total.

js
import { slowDown } from 'express-slow-down'
import { rateLimit } from 'express-rate-limit'

const throttle = slowDown({
  windowMs: 10000,
  delayAfter: 3,
  delayMs: (hits) => (hits - 3) * (hits - 3) * 1000,
})

const limiter = rateLimit({
  limit: 7,
  windowMs: 30000,
  message: 'Too many requests',
  standardHeaders: 'draft-6',
  legacyHeaders: false,
})

A fórmula do atraso é montada de trás para frente, a partir do padrão exigido. O hit 4 precisa dar 1, o hit 5 precisa dar 4, o hit 6 precisa dar 9, que são 1², 2², 3². Subtrair delayAfter transforma o número do hit na base, e elevar ao quadrado produz a curva.

Execute sete requisições rápidas e algo inesperado acontece. A requisição 7, atrasada em dezesseis segundos, chega ao limiter depois da requisição 8, que teve um cronômetro mais curto. A requisição 8 se torna o sétimo sucesso e a requisição 7 é recusada.

Requisições throttled não formam uma fila. Cada uma tem seu próprio cronômetro e chega quando chega.

JunoCombinando um throttle e um limiter A reordenação parece um bug, mas não é. Cada requisição atrasada recebeu seu próprio cronômetro quando chegou, então uma requisição com cronômetro curto termina antes de uma com cronômetro longo, independentemente da ordem em que chegaram.

Um leaky bucket as manteria em fila. Um throttle distribui cronômetros e os deixa correr.

JunoCombinando um throttle e um limiter O que significa que throttle mais limiter juntos podem recusar uma requisição que um limiter sozinho teria aceitado. A requisição 7 estava dentro do limite quando chegou e fora dele no momento em que seu atraso expirou.

Vale saber disso antes de tentar depurar isso como se fosse uma falha do limiter. O limiter contou corretamente; o throttle mudou o momento em que a requisição apareceu para ser contada.

JunoCombinando um throttle e um limiter Nenhum dos dois middlewares aqui identifica o cliente, então todo esse tráfego compartilha um único orçamento. Adicionar keyGenerator aos dois é o que transforma isso em uma limitação por cliente, na qual um usuário pesado é desacelerado e todos os outros continuam sendo atendidos imediatamente.

Sem isso, a rajada de um único cliente atrasa todo mundo, o que transforma um controle de proteção em uma negação de serviço autoinfligida.

A configuração que se sustenta em produção tem três camadas: identificar o cliente, usar throttle para moldar o tráfego dele, e recusar depois de um teto rígido. Cada camada responde a uma pergunta diferente, e os resultados ficam complicados o suficiente para valer a pena desenhar um gráfico de horário de chegada, atraso e resultado antes de confiar na sua configuração.

Coloque em prática

Um endpoint está configurado com windowMs: 10000, delayAfter: 2, delayMs: (hits) => hits * hits * 1000, e nenhum rate limiter.

  1. Quatro requisições chegam ao mesmo tempo. Quando cada uma responde?
  2. Um cliente envia quatro requisições a cada 15 segundos, para sempre. Os atrasos pioram com o tempo?
  3. O que acontece se uma quinta requisição chega e seu atraso ultrapassa o timeout do cliente?
  4. Como você mudaria a configuração para que um único cliente não conseguisse deixar todo mundo mais lento?
Compare suas respostas
#Resposta
1As requisições 1 e 2 respondem imediatamente. A requisição 3 é o hit 3, então 3² = 9 segundos. A requisição 4 é o hit 4, então 4² = 16 segundos
2Não. A janela é de 10 segundos e o intervalo é de 15, então cada rajada encontra uma janela vazia e a contagem recomeça do zero. O mesmo padrão de duas livres, depois 9s, depois 16s se repete para sempre
3O cliente desiste e seu servidor continua segurando a conexão até o atraso expirar. O trabalho é feito e ninguém o recebe, e é por isso que atrasos exponenciais precisam de maxDelayMs
4Adicione um keyGenerator para que o throttle conte por cliente em vez de globalmente. Sem isso, cada requisição compartilha um único orçamento e um cliente pesado atrasa todo mundo

A pergunta 2 é a que costuma surpreender as pessoas. Um atraso exponencial parece que deveria punir o abuso sustentado, mas um cliente que espaça suas rajadas para ficar fora da janela nunca sente esse crescimento. A curva só morde dentro de uma única janela.

Para onde isso leva

Cinco algoritmos e dois controles, todos respondendo a uma única pergunta: quanto esse cliente pode pedir, e o que acontece quando ele pede mais que isso.

O raciocínio vale além dos seus próprios servidores. Toda API externa que você chama tem seus próprios limites, então entender isso melhora também o seu código de cliente: como ele recua, e como ele lida com um 429 em vez de continuar martelando um serviço que já está sobrecarregado.

Com isso, encerramos o handbook. Cinco seções, desde pensar como um atacante até segurança de entrada, identidade e prevenção de abuso, cada uma construída sobre um exemplo funcional que você pode quebrar e depois consertar.