# Rate limiting distribuído: algoritmos, implementação atômica e políticas de falha

> Como escolher entre janela fixa, janela deslizante, token bucket e leaky bucket, manter o contador correto com várias réplicas usando um script Lua no Redis, responder 429 com os cabeçalhos RateLimit e decidir o que fazer quando o Redis cai.

URL: https://www.mfactor.dev/blog/rate-limiting-distribuido
Autor: Alexandre Martins
Publicado em: 2026-10-27
Assuntos: Rate Limiting, Redis, TypeScript, API Design, Distributed Systems
Site: MFactor — https://www.mfactor.dev

Considere uma API de pedidos com três réplicas atrás de um balanceador. Um cliente integrado entra em um loop defeituoso e passa a enviar milhares de requisições por segundo. O pool de conexões do banco se esgota e a latência sobe para todos os clientes. O limite por cliente existe no código, mas cada réplica conta em memória local, e o balanceador distribui as requisições entre as três.

O limite efetivo fica três vezes maior que o configurado, e indefinido quando o autoscaling muda o número de réplicas. O rate limiting tem três objetivos: proteger o serviço e as dependências, distribuir a capacidade com equidade entre clientes e manter o custo previsível. Nenhum deles é atendido por um contador que cada processo mantém para si.

> Como aplicar um limite por cliente que valha para todas as réplicas, decida em um único round trip e tenha comportamento definido quando o armazenamento compartilhado falhar?

## O que a resposta 429 comunica

A RFC 6585 define o status 429 Too Many Requests para o cliente que enviou requisições demais em um intervalo. O cabeçalho Retry-After, da RFC 9110, informa quanto esperar. O rascunho RateLimit header fields for HTTP, do IETF, acrescenta dois campos estruturados. RateLimit-Policy declara a cota: q é a quantidade e w a janela em segundos. RateLimit declara o estado: r é o que resta e t os segundos até a reposição.

Esses cabeçalhos transformam o limite em contrato. O cliente que os lê espaça as chamadas antes de receber 429, e o que recebe 429 sabe quando repetir.

## Algoritmos e seus custos

Cinco algoritmos cobrem a maioria das implementações, cada um com uma troca entre precisão, memória e pico aceito:

1. Janela fixa (fixed window): um contador por intervalo, zerado na fronteira. Um cliente pode usar a cota inteira no fim de um minuto e de novo no início do seguinte, o dobro do limite em segundos.
2. Log deslizante (sliding window log): um timestamp por requisição, com remoção dos que saíram da janela. É exato, mas a memória cresce com o limite e cada decisão percorre o log.
3. Contador deslizante (sliding window counter): contadores da janela atual e da anterior, combinados pelo peso da fração da anterior que ainda está no intervalo. Custo O(1) e boa aproximação em tráfego regular.
4. Token bucket: um balde de capacidade b recebe r tokens por segundo, e cada requisição retira um. Aceita picos até b e taxa média r.
5. Leaky bucket: uma fila que escoa a taxa constante, e a requisição que não cabe é rejeitada. Suaviza a saída em vez de aceitar picos.

O token bucket é a escolha mais comum para APIs: a cota fica em dois parâmetros com significado para o cliente, o pico tolerado e a taxa sustentada.

## Estado compartilhado e atomicidade

Mover o contador para o Redis resolve a divergência entre réplicas, mas introduz uma corrida. Ler, decidir e gravar são três operações, e duas réplicas que leem o mesmo valor ao mesmo tempo aceitam as duas requisições quando só havia um token. MULTI e EXEC não bastam, porque a decisão depende do valor lido no meio da transação.

A documentação do Redis garante a execução atômica de scripts Lua enviados com EVAL: durante o script, nenhuma outra atividade do servidor ocorre. Leitura, reposição, decisão e gravação viram uma operação em um único round trip. O relógio pode ser o do próprio Redis, pelo comando TIME, o que elimina o desvio entre as réplicas da aplicação.

A chave carrega o escopo do limite. O par cliente e rota, como rl:{cliente:42}:POST:/pedidos, permite cotas por operação, e a hash tag entre chaves mantém as chaves de um cliente no mesmo nó do cluster.

## Exemplo: token bucket em Lua e middleware em TypeScript

O script recebe uma chave e três argumentos e devolve três inteiros: permitido ou não, tokens restantes e segundos até haver tokens suficientes.

```lua
-- KEYS[1]: chave do bucket, ex.: rl:{cliente:42}:POST:/pedidos
-- ARGV[1]: capacidade   ARGV[2]: tokens por segundo   ARGV[3]: custo da requisição
local capacidade = tonumber(ARGV[1])
local taxa = tonumber(ARGV[2])
local custo = tonumber(ARGV[3])

local t = redis.call('TIME')
local agora = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)

local estado = redis.call('HMGET', KEYS[1], 'tokens', 'atualizado')
local tokens = tonumber(estado[1])
local atualizado = tonumber(estado[2])
if tokens == nil then              -- bucket novo começa cheio
  tokens = capacidade
  atualizado = agora
end

local decorrido = math.max(0, agora - atualizado) / 1000
tokens = math.min(capacidade, tokens + decorrido * taxa)

local permitido = 0
local espera = 0                   -- segundos até haver tokens suficientes, para o Retry-After
if tokens >= custo then
  tokens = tokens - custo
  permitido = 1
else
  espera = math.ceil((custo - tokens) / taxa)
end

redis.call('HSET', KEYS[1], 'tokens', tokens, 'atualizado', agora)
-- Expira quando o bucket estaria cheio de novo: clientes inativos não deixam lixo.
redis.call('PEXPIRE', KEYS[1], math.ceil(capacidade / taxa * 1000))

return { permitido, math.floor(tokens), espera }
```

O middleware registra o script com defineCommand do ioredis, que usa EVALSHA, e é anexado por rota para que req.route exista na chave.

```typescript
import Redis from 'ioredis';
import type { Request, Response, NextFunction } from 'express';
import { readFileSync } from 'node:fs';

declare module 'ioredis' {
  interface RedisCommander<Context> {
    tokenBucket(chave: string, capacidade: number, taxa: number, custo: number): Promise<[number, number, number]>;
  }
}

const redis = new Redis({ host: 'redis', commandTimeout: 50, maxRetriesPerRequest: 0 });
redis.defineCommand('tokenBucket', { numberOfKeys: 1, lua: readFileSync('token-bucket.lua', 'utf8') });

type Politica = { capacidade: number; taxa: number; custo?: number; falharAberto?: boolean };

export function rateLimit({ capacidade, taxa, custo = 1, falharAberto = true }: Politica) {
  const janela = Math.ceil(capacidade / taxa);     // segundos para encher o bucket

  return async (req: Request, res: Response, next: NextFunction) => {
    const cliente = res.locals.clientId ?? req.ip;
    const chave = `rl:{${cliente}}:${req.method}:${req.route?.path ?? req.path}`;
    let resultado: [number, number, number];
    try {
      resultado = await redis.tokenBucket(chave, capacidade, taxa, custo);
    } catch (erro) {
      // Redis indisponível: a política da rota decide.
      console.warn('rate limit sem Redis', chave, erro);
      if (falharAberto) return next();
      return res.status(503).set('Retry-After', '5').end();
    }
    const [permitido, restante, espera] = resultado;
    res.set('RateLimit-Policy', `"bucket";q=${capacidade};w=${janela}`);
    res.set('RateLimit', `"bucket";r=${restante};t=${Math.ceil((capacidade - restante) / taxa)}`);
    if (permitido === 1) return next();

    res.set('Retry-After', String(espera));
    return res.status(429).json({ erro: 'limite de requisições excedido', tentarEm: espera });
  };
}
```

Com capacidade 100 e taxa 2, o cliente dispara 100 requisições de uma vez e depois sustenta 2 por segundo, e o bucket vazio leva 50 segundos para encher.

```mermaid
flowchart TD
  req["Requisição autenticada"] --> chave["Chave rl:{cliente}:método:rota"]
  chave --> lua["EVALSHA do script token bucket"]
  lua --> redisOk{"Redis respondeu?"}
  redisOk -- "não" --> politica{"Política da rota"}
  politica -- "fail open" --> libera["Libera e registra alerta"]
  politica -- "fail closed" --> r503["503 com Retry-After"]
  redisOk -- "sim" --> tokens{"Tokens suficientes?"}
  tokens -- "sim" --> headers["Cabeçalhos RateLimit e RateLimit-Policy"]
  headers --> next["Segue para o handler"]
  tokens -- "não" --> r429["429 com Retry-After e RateLimit"]
  libera --> next
```

## Posicionamento: gateway, middleware ou serviço

O limite pode ser aplicado em três lugares, e eles se somam. No gateway, a política vale para todas as APIs antes de qualquer código de aplicação. O autor tratou dessa camada em artigo sobre governança de APIs com o Apigee, em que a política Quota limita as chamadas por minuto de cada cliente. O custo é a granularidade: o gateway conhece o cliente e a rota, não o custo de negócio de cada operação.

No middleware, como no exemplo, a chave inclui o que só o serviço sabe: plano do cliente, custo de uma consulta pesada, tenant. Em um serviço dedicado, consultado por gRPC pelas réplicas e pelo gateway, a política fica em um só lugar, ao custo de um salto de rede a mais.

## Política quando o Redis está indisponível

A chamada ao Redis falha por timeout, conexão recusada ou failover, e o middleware precisa de uma decisão prévia. Fail open deixa a requisição passar e registra o evento: o serviço fica sem proteção pelo tempo da falha, mas continua atendendo. Fail closed nega com 503 e Retry-After: o serviço fica protegido, e nenhum cliente é atendido.

A escolha depende do que o limite protege. Para a maioria das rotas, fail open com alerta é adequado, porque a indisponibilidade do Redis já é um incidente e não deve virar dois. Para rotas em que cada requisição tem custo direto, como envio de SMS, login ou chamada a uma API paga, fail closed é a opção segura. O timeout de 50 milissegundos e maxRetriesPerRequest zero, no exemplo, impedem que um Redis lento derrube a latência da API inteira.

## Desafios

Chaves quentes concentram carga em um nó do cluster. Um cliente com milhares de requisições por segundo bate sempre na mesma chave. Dividir o bucket em N sub-buckets com uma fração da taxa cada, escolhidos por hash, distribui a carga ao custo de precisão.

O rascunho dos cabeçalhos RateLimit ainda muda entre versões, e clientes antigos procuram X-RateLimit-Limit, X-RateLimit-Remaining e X-RateLimit-Reset. Emitir as duas famílias durante a transição é um custo pequeno.

Limite por IP pune usuários atrás do mesmo NAT corporativo e não pune quem alterna endereços. A identidade autenticada resolve o primeiro problema, e limites em camadas, por cliente e por conta, atenuam o segundo.

Por fim, equidade não é prioridade: um limite uniforme protege o serviço, mas não reserva capacidade para um cliente crítico. Planos com capacidade e taxa distintos tratam disso, ao custo de uma política por plano.

## Boas práticas

1. Use token bucket com capacidade e taxa documentadas por rota, e trate a capacidade como o pico que o serviço suporta.
2. Mantenha o estado no Redis e decida com um script Lua, nunca com leitura e escrita em comandos separados.
3. Monte a chave com a identidade autenticada e a rota, e use hash tags para manter as chaves de um cliente no mesmo nó.
4. Responda 429 com Retry-After, RateLimit e RateLimit-Policy, e emita os dois últimos também nas respostas permitidas.
5. Defina por rota o comportamento sem Redis: fail open com alerta por padrão e fail closed onde cada requisição tem custo direto.
6. Limite o timeout da chamada ao Redis a dezenas de milissegundos e desative os retries internos do cliente.
7. Combine o limite do gateway, que protege a plataforma, com o do middleware, que conhece o custo de cada operação.

## Conclusão

Rate limiting distribuído é um problema de estado compartilhado disfarçado de problema de algoritmo. Os cinco algoritmos se diferenciam em memória e na forma do pico que aceitam, e o token bucket atende à maioria dos casos com dois parâmetros legíveis. A parte difícil é manter esse estado correto com várias réplicas, e um script Lua no Redis resolve isso em um round trip.

As decisões que restam são de política: onde posicionar cada camada de limite, como identificar o cliente e o que fazer quando o Redis não responde. Com essas respostas escritas antes do primeiro 429, o limite passa a ser um contrato que o cliente consegue respeitar, e não uma surpresa descoberta pelo erro.

## Referências

- IETF, RFC 6585: Additional HTTP Status Codes, seção 4 (429 Too Many Requests). https://www.rfc-editor.org/rfc/rfc6585.html
- IETF, RFC 9110: HTTP Semantics, seção 10.2.3 (Retry-After). https://www.rfc-editor.org/rfc/rfc9110.html#name-retry-after
- IETF, RateLimit header fields for HTTP (Internet-Draft, HTTPAPI). https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/
- Redis, Scripting with Lua (EVAL, atomicidade, TIME e replicação de efeitos). https://redis.io/docs/latest/develop/programmability/eval-intro/
- Alexandre Martins, Governança e Automação de APIs em Larga Escala com Apigee. https://medium.com/@alexandremartinx/governan%C3%A7a-e-automa%C3%A7%C3%A3o-de-apis-em-larga-escala-com-apigee-8ab9f9cecbce
