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:
TIMEdá 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 };
}import os
from pathlib import Path
import redis
cliente_redis = redis.Redis.from_url(os.environ["REDIS_URL"])
# register_script usa EVALSHA: o script é enviado ao Redis só uma vez
balde = cliente_redis.register_script(Path(__file__).with_name("balde.lua").read_text())
def consumir(cliente: str, capacidade: int = 10, por_segundo: float = 5) -> dict:
permitido, restantes, espera = balde(keys=[f"limite:{cliente}"], args=[capacidade, por_segundo])
return {"permitido": permitido == 1, "restantes": restantes, "esperar_segundos": espera}package limite
import (
"context"
_ "embed"
"fmt"
"github.com/redis/go-redis/v9"
)
//go:embed balde.lua
var codigoBalde string
// NewScript usa EVALSHA e reenvia o script só se o Redis não o tiver
var balde = redis.NewScript(codigoBalde)
type Decisao struct {
Permitido bool
Restantes int64
EsperarSegundos int64
}
func Consumir(ctx context.Context, rdb *redis.Client, cliente string, capacidade int, porSegundo float64) (Decisao, error) {
r, err := balde.Run(ctx, rdb, []string{"limite:" + cliente}, capacidade, fmt.Sprint(porSegundo)).Int64Slice()
if err != nil {
return Decisao{}, err
}
return Decisao{Permitido: r[0] == 1, Restantes: r[1], EsperarSegundos: r[2]}, nil
}import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import redis.clients.jedis.JedisPooled;
public class Limitador {
public record Decisao(boolean permitido, long restantes, long esperarSegundos) {}
private final JedisPooled redis;
private final String balde;
public Limitador(JedisPooled redis) throws Exception {
this.redis = redis;
this.balde = Files.readString(Path.of("balde.lua"));
}
@SuppressWarnings("unchecked")
public Decisao consumir(String cliente, int capacidade, double porSegundo) {
var r = (List<Long>) redis.eval(balde,
List.of("limite:" + cliente),
List.of(String.valueOf(capacidade), String.valueOf(porSegundo)));
return new Decisao(r.get(0) == 1, r.get(1), r.get(2));
}
}using StackExchange.Redis;
public record Decisao(bool Permitido, long Restantes, long EsperarSegundos);
public class Limitador(IConnectionMultiplexer conexao)
{
private static readonly string Balde = File.ReadAllText("balde.lua");
private readonly IDatabase redis = conexao.GetDatabase();
public async Task<Decisao> Consumir(string cliente, int capacidade = 10, double porSegundo = 5)
{
var r = (RedisResult[])(await redis.ScriptEvaluateAsync(
Balde,
[new RedisKey($"limite:{cliente}")],
[capacidade, porSegundo]))!;
return new Decisao((long)r[0] == 1, (long)r[1], (long)r[2]);
}
}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 20 | Depois de 1 segundo, 10 chamadas | Espera indicada na recusa | |
|---|---|---|---|
| TypeScript | 10 aceitas | 5 aceitas | 1 s |
| Python | 10 aceitas | 5 aceitas | 1 s |
| Go | 10 aceitas | 5 aceitas | 1 s |
| Java | 10 aceitas | 5 aceitas | 1 s |
| C# | 10 aceitas | 5 aceitas | 1 s |
O que muda de uma linguagem para outra
| Cliente de Redis | Como o script é enviado | |
|---|---|---|
| TypeScript | redis (node-redis) | eval envia o script inteiro a cada chamada; defineScript na criação do cliente usa EVALSHA |
| Python | redis-py | register_script usa EVALSHA e reenvia só se o Redis não tiver o script |
| Go | go-redis | redis.NewScript usa EVALSHA com reenvio automático |
| Java | Jedis | eval envia o script inteiro; scriptLoad e evalsha para enviar uma vez |
| C# | StackExchange.Redis | ScriptEvaluateAsync 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:
| Teste | Limitador ruim | Limitador correto |
|---|---|---|
| Limite de 10/s; 10 pedidos no fim de um segundo e 10 no começo do seguinte | Janela fixa: 20 aceitos em 0,11 s | Balde ou janela deslizante: não passa do limite |
| 20 pedidos simultâneos, limite de 10 | GET e SET separados: 20 aceitos | INCR ou script Lua: 10 aceitos |
| 30 pedidos por um balanceador com 2 instâncias, limite de 10 | Contagem em memória: 20 aceitos | Contagem 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-Forquando a conexão vem do proxy? - Está decidido o que acontece se o Redis cair, com alerta?
Resposta
- O excesso recebe
429, comRetry-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]