DevGuideTK
← Trilha Backend

Limite de requisições

Algoritmos para limitar o uso de uma API e proteger o serviço contra abuso e sobrecarga.

O balde de fichas desta página foi executado nas cinco linguagens contra um Redis 7. A lógica fica num script Lua, que o Redis executa de forma atômica; cada linguagem só chama o script.

O algoritmo, num script Lua

-- Balde de fichas: KEYS[1] = chave do cliente; ARGV[1] = capacidade; ARGV[2] = fichas por segundo
local chave = KEYS[1]
local capacidade = tonumber(ARGV[1])
local por_segundo = tonumber(ARGV[2])

local t = redis.call('TIME') -- o relógio do Redis, igual para todas as instâncias
local agora = tonumber(t[1]) + tonumber(t[2]) / 1000000

local estado = redis.call('HMGET', chave, 'fichas', 'atualizado')
local fichas = tonumber(estado[1]) or capacidade
local atualizado = tonumber(estado[2]) or agora

-- repõe as fichas acumuladas desde o último pedido, sem passar da capacidade
fichas = math.min(capacidade, fichas + (agora - atualizado) * por_segundo)

local permitido = 0
if fichas >= 1 then
  fichas = fichas - 1
  permitido = 1
end

redis.call('HSET', chave, 'fichas', string.format('%.6f', fichas), 'atualizado', string.format('%.6f', agora))
redis.call('EXPIRE', chave, math.ceil(capacidade / por_segundo) + 1)

local espera = 0
if permitido == 0 then
  espera = math.ceil((1 - fichas) / por_segundo)
end
return { permitido, math.floor(fichas), espera }

Três decisões do script:

  • Atomicidade: o Redis executa o script inteiro sem intercalar outros comandos, então ler as fichas, calcular e gravar acontecem como uma operação só, mesmo com pedidos simultâneos de várias instâncias.
  • Relógio do Redis: TIME dá o mesmo horário para todas as instâncias da aplicação.
  • Expiração: a chave some sozinha depois do tempo necessário para o balde encher de novo, sem acumular dados de clientes inativos.

Chamar o script, em cada linguagem

import { readFileSync } from "node:fs";
import { createClient } from "redis";

const redis = await createClient({ url: process.env.REDIS_URL }).connect();
const BALDE = readFileSync(new URL("./balde.lua", import.meta.url), "utf8");

export type Decisao = { permitido: boolean; restantes: number; esperarSegundos: number };

export async function consumir(cliente: string, capacidade = 10, porSegundo = 5): Promise<Decisao> {
  const [permitido, restantes, espera] = (await redis.eval(BALDE, {
    keys: [`limite:${cliente}`],
    arguments: [String(capacidade), String(porSegundo)],
  })) as number[];
  return { permitido: permitido === 1, restantes, esperarSegundos: espera };
}

O resultado, nas cinco linguagens

Com capacidade de 10 fichas e reposição de 5 por segundo, cada versão fez uma rajada de 20 chamadas simultâneas, esperou um segundo e fez mais 10:

Rajada de 20Depois de 1 segundo, 10 chamadasEspera indicada na recusa
TypeScript10 aceitas5 aceitas1 s
Python10 aceitas5 aceitas1 s
Go10 aceitas5 aceitas1 s
Java10 aceitas5 aceitas1 s
C#10 aceitas5 aceitas1 s

O que muda de uma linguagem para outra

Cliente de RedisComo o script é enviado
TypeScriptredis (node-redis)eval envia o script inteiro a cada chamada; defineScript na criação do cliente usa EVALSHA
Pythonredis-pyregister_script usa EVALSHA e reenvia só se o Redis não tiver o script
Gogo-redisredis.NewScript usa EVALSHA com reenvio automático
JavaJediseval envia o script inteiro; scriptLoad e evalsha para enviar uma vez
C#StackExchange.RedisScriptEvaluateAsync com o texto; LuaScript.Prepare e LoadedLuaScript para reaproveitar

Com EVALSHA, o Redis guarda o script e as chamadas seguintes enviam só o hash dele. Para um script de algumas centenas de bytes chamado a cada pedido, a diferença é pequena, mas o caminho com EVALSHA é o recomendado em produção.

Os resultados do script chegam como inteiros: o Redis converte números do Lua para inteiros ao devolvê-los. Por isso o script devolve as fichas restantes arredondadas para baixo, e a espera arredondada para cima.

Aplicar numa rota

app.use(async (req, res, next) => {
  const cliente = req.clienteId ?? `ip:${req.ip}`; // req.ip só é confiável com o proxy configurado
  let decisao;
  try {
    decisao = await consumir(cliente, 10, 5);
  } catch (erro) {
    registrarFalhaDoLimitador(erro);
    return next(); // Redis indisponível: deixa passar, com alerta
  }

  res.setHeader("RateLimit", `"padrao";r=${decisao.restantes};t=${decisao.esperarSegundos}`);
  if (decisao.permitido) return next();

  res.setHeader("Retry-After", String(decisao.esperarSegundos));
  res.status(429).type("application/problem+json").json({
    type: "https://api.loja.com.br/erros/limite-excedido",
    title: "Limite de requisições excedido",
    status: 429,
  });
});

Os testes que mostraram os problemas

Os resultados do nível Conceito vieram de três testes simples, que valem como teste automatizado de qualquer limitador:

TesteLimitador ruimLimitador correto
Limite de 10/s; 10 pedidos no fim de um segundo e 10 no começo do seguinteJanela fixa: 20 aceitos em 0,11 sBalde ou janela deslizante: não passa do limite
20 pedidos simultâneos, limite de 10GET e SET separados: 20 aceitosINCR ou script Lua: 10 aceitos
30 pedidos por um balanceador com 2 instâncias, limite de 10Contagem em memória: 20 aceitosContagem no Redis: 10 aceitos

Lista de revisão

Contagem

  • A contagem é compartilhada entre as instâncias, e não guardada em memória?
  • A conferência e a atualização são uma única operação atômica?
  • O algoritmo trata a virada de janela, se isso importar?
  • As chaves expiram sozinhas?

Identificação e falhas

  • O cliente é identificado pela chave de API ou pelo usuário, quando existir?
  • O IP só é lido de X-Forwarded-For quando a conexão vem do proxy?
  • Está decidido o que acontece se o Redis cair, com alerta?

Resposta

  • O excesso recebe 429, com Retry-After?
  • O estado do limite aparece também nas respostas de sucesso?
  • Login e rotas com custo, como SMS, têm limites próprios?

Prompts para aprofundar

Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.

  • Implementar e testar um balde de fichas

    Quero implementar um limite de requisições com balde de fichas no Redis e testar que ele está correto.
    
    Pergunte qual linguagem e cliente de Redis eu uso.
    
    Vamos em etapas:
    1. Escrever o script Lua do balde de fichas, usando o relógio do Redis.
    2. Chamar o script da minha linguagem, com EVALSHA.
    3. Testar uma rajada de chamadas simultâneas acima da capacidade e conferir quantas passam.
    4. Esperar e conferir a reposição.
    5. Comparar com uma janela fixa, testando a virada da janela.
    6. Comparar com uma versão que lê e depois grava, testando com chamadas simultâneas.
    
    Em cada etapa, peça que eu preveja o resultado antes de rodar.
  • Middleware de limite com cabeçalhos

    Quero criar um middleware de limite de requisições para a minha API, com as respostas e cabeçalhos corretos.
    
    Pergunte qual linguagem e framework eu uso.
    
    Explique, com código:
    1. Como identificar o cliente, com a configuração correta de proxy confiável.
    2. Como chamar o limitador e tratar a falha do Redis.
    3. A resposta 429 com Retry-After e corpo no formato Problem Details.
    4. Os cabeçalhos de estado do limite nas respostas de sucesso.
    5. Limites diferentes por rota e por plano.
    6. Testes automatizados para cada comportamento.
  • Revisar um limitador de requisições

    Vou colar abaixo o código do limitador de requisições da minha API. Revise com foco em correção e segurança.
    
    Verifique, e para cada item diga se está correto, incorreto ou ausente, citando a linha:
    1. A contagem é compartilhada entre as instâncias?
    2. A conferência e a atualização são atômicas?
    3. O algoritmo deixa passar mais que o limite na virada da janela?
    4. O cliente é identificado de forma confiável, sem confiar em X-Forwarded-For de qualquer origem?
    5. As chaves expiram?
    6. O comportamento com o Redis fora do ar está definido?
    7. A resposta 429 informa quando tentar de novo?
    
    Para cada problema, descreva como ele seria explorado ou como falharia em produção, e mostre a correção.
    
    Código:
    [cole aqui]