Ir para o conteúdo
Backend

Rate Limiting Inteligente com Redis e Node.js

Marcos Soares
Atualizado em 
12 minutos de leitura
Ilustracao 3D de ampulheta de vidro com mecanismo de controle de fluxo representando rate limiting com Redis e Node.js
Ouça este artigo
0:00Rate Limiting Inteligente com Redis e Node.js--:--

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.

Neste artigo

O Problema Real

Sua API está em produção. Um cliente mal configurado dispara 10.000 requests em 5 segundos. O banco engasga. Todos os outros clientes sofrem.

Rate limiting não é opcional. É infraestrutura básica.

Mas a maioria das implementações que vejo em produção são ingênuas. Usam fixed window, armazenam contadores em memória local e quebram com múltiplas instâncias. Vamos fazer diferente.

O Que Vamos Construir

Um sistema de rate limiting com três camadas:

  • Sliding Window: precisão sem os problemas do fixed window
  • Token Bucket: burst controlado para clientes legítimos
  • Limites dinâmicos por tier: free, pro e enterprise com regras diferentes

Tudo com Redis como backend distribuído. Funciona com 1 ou 50 instâncias da API.

Setup Inicial

Primeiro, o projeto:

Bash
mkdir rate-limiter-api && cd rate-limiter-api
npm init -y
npm install express ioredis dotenv
npm install -D typescript @types/express @types/node tsx
npx tsc --init

Configure o tsconfig.json mínimo:

JSON
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"]
}

E o .env:

.env
REDIS_URL=redis://localhost:6379
PORT=3000

Suba um Redis local se não tiver:

Bash
docker run -d --name redis-limiter -p 6379:6379 redis:7-alpine

Conexão com Redis

Crie o cliente Redis com reconexão automática:

TYPESCRIPT
// src/lib/redis.ts
import Redis from "ioredis";
import "dotenv/config";
 
const redis = new Redis(process.env.REDIS_URL!, {
  maxRetriesPerRequest: 3,
  retryStrategy(times) {
    const delay = Math.min(times * 50, 2000);
    return delay;
  },
  enableReadyCheck: true,
});
 
redis.on("error", (err) => {
  console.error("[Redis] Erro de conexão:", err.message);
});
 
redis.on("connect", () => {
  console.log("[Redis] Conectado");
});
 
export default redis;

O retryStrategy é importante. Em produção, o Redis pode ter blips de rede. Sem retry, sua API inteira para de responder.

Algoritmo 1: Sliding Window Log

O fixed window tem um problema clássico. Se o limite é 100 req/min, um cliente pode fazer 100 requests no segundo 59 e mais 100 no segundo 61. São 200 requests em 2 segundos.

O sliding window resolve isso.

TYPESCRIPT
// src/limiters/sliding-window.ts
import redis from "../lib/redis";
 
interface SlidingWindowResult {
  allowed: boolean;
  remaining: number;
  retryAfter: number | null;
  total: number;
}
 
export async function slidingWindowLimit(
  key: string,
  maxRequests: number,
  windowMs: number
): Promise<SlidingWindowResult> {
  const now = Date.now();
  const windowStart = now - windowMs;
  const member = `${now}:${Math.random().toString(36).slice(2, 8)}`;
 
  // Lua script para atomicidade
  const luaScript = `
    local key = KEYS[1]
    local window_start = tonumber(ARGV[1])
    local now = tonumber(ARGV[2])
    local max_requests = tonumber(ARGV[3])
    local window_ms = tonumber(ARGV[4])
    local member = ARGV[5]
 
    -- Remove entradas fora da janela
    redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)
 
    -- Conta requests na janela atual
    local current_count = redis.call('ZCARD', key)
 
    if current_count < max_requests then
      -- Adiciona novo request
      redis.call('ZADD', key, now, member)
      redis.call('PEXPIRE', key, window_ms)
      return {1, max_requests - current_count - 1, 0}
    else
      -- Pega o request mais antigo para calcular retry
      local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
      local retry_after = 0
      if #oldest > 0 then
        retry_after = tonumber(oldest[2]) + window_ms - now
      end
      return {0, 0, retry_after}
    end
  `;
 
  const result = (await redis.eval(
    luaScript,
    1,
    key,
    windowStart,
    now,
    maxRequests,
    windowMs,
    member
  )) as number[];
 
  return {
    allowed: result[0] === 1,
    remaining: result[1],
    retryAfter: result[0] === 0 ? Math.ceil(result[2] / 1000) : null,
    total: maxRequests,
  };
}

Por que Lua script? Atomicidade. Sem ele, entre o ZCARD e o ZADD, outro request poderia passar. Em alta concorrência, isso destrói a precisão do limiter.

Algoritmo 2: Token Bucket

O sliding window é preciso, mas rígido. O token bucket permite bursts controlados. Ideal para APIs onde picos curtos são aceitáveis.

A ideia é simples. Um balde tem N tokens. Cada request consome um. Tokens são repostos a uma taxa fixa.

TYPESCRIPT
// src/limiters/token-bucket.ts
import redis from "../lib/redis";
 
interface TokenBucketResult {
  allowed: boolean;
  remainingTokens: number;
  retryAfter: number | null;
}
 
export async function tokenBucketLimit(
  key: string,
  bucketSize: number,
  refillRate: number, // tokens por segundo
  tokensRequired: number = 1
): Promise<TokenBucketResult> {
  const luaScript = `
    local key = KEYS[1]
    local bucket_size = tonumber(ARGV[1])
    local refill_rate = tonumber(ARGV[2])
    local tokens_required = tonumber(ARGV[3])
    local now = tonumber(ARGV[4])
 
    local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
    local tokens = tonumber(bucket[1])
    local last_refill = tonumber(bucket[2])
 
    -- Inicializa bucket se não existe
    if tokens == nil then
      tokens = bucket_size
      last_refill = now
    end
 
    -- Calcula tokens a adicionar desde último refill
    local elapsed = (now - last_refill) / 1000
    local new_tokens = elapsed * refill_rate
    tokens = math.min(bucket_size, tokens + new_tokens)
 
    local allowed = 0
    local retry_after = 0
 
    if tokens >= tokens_required then
      tokens = tokens - tokens_required
      allowed = 1
    else
      -- Tempo até ter tokens suficientes
      local deficit = tokens_required - tokens
      retry_after = math.ceil(deficit / refill_rate)
    end
 
    -- Salva estado
    redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
    redis.call('EXPIRE', key, math.ceil(bucket_size / refill_rate) + 10)
 
    return {allowed, math.floor(tokens), retry_after}
  `;
 
  const now = Date.now();
  const result = (await redis.eval(
    luaScript,
    1,
    key,
    bucketSize,
    refillRate,
    tokensRequired,
    now
  )) as number[];
 
  return {
    allowed: result[0] === 1,
    remainingTokens: result[1],
    retryAfter: result[0] === 0 ? result[2] : null,
  };
}

Repare no EXPIRE do hash. Sem ele, chaves de clientes inativos ficam no Redis para sempre. Memory leak silencioso.

Sistema de Tiers Dinâmico

APIs sérias têm planos. Cada plano tem limites diferentes. Vamos modelar isso:

TYPESCRIPT
// src/config/tiers.ts
export interface TierConfig {
  name: string;
  requestsPerMinute: number;
  requestsPerDay: number;
  burstSize: number;
  refillRate: number; // tokens/segundo
}
 
export const TIERS: Record<string, TierConfig> = {
  free: {
    name: "Free",
    requestsPerMinute: 20,
    requestsPerDay: 1_000,
    burstSize: 10,
    refillRate: 0.33, // ~20 por minuto
  },
  pro: {
    name: "Pro",
    requestsPerMinute: 200,
    requestsPerDay: 50_000,
    burstSize: 50,
    refillRate: 3.33, // ~200 por minuto
  },
  enterprise: {
    name: "Enterprise",
    requestsPerMinute: 2_000,
    requestsPerDay: 1_000_000,
    burstSize: 200,
    refillRate: 33.33,
  },
};
 
export function getTier(apiKey: string): TierConfig {
  // Em produção: consulta no banco/cache
  if (apiKey.startsWith("ent_")) return TIERS.enterprise;
  if (apiKey.startsWith("pro_")) return TIERS.pro;
  return TIERS.free;
}

Middleware Unificado

Agora juntamos tudo em um middleware Express que aplica ambos os algoritmos:

TYPESCRIPT
// src/middleware/rate-limit.ts
import { Request, Response, NextFunction } from "express";
import { slidingWindowLimit } from "../limiters/sliding-window";
import { tokenBucketLimit } from "../limiters/token-bucket";
import { getTier, TierConfig } from "../config/tiers";
 
function extractApiKey(req: Request): string | null {
  const header = req.headers["x-api-key"];
  if (typeof header === "string") return header;
 
  const authHeader = req.headers.authorization;
  if (authHeader?.startsWith("Bearer ")) {
    return authHeader.slice(7);
  }
 
  return null;
}
 
function setRateLimitHeaders(
  res: Response,
  limit: number,
  remaining: number,
  retryAfter: number | null
) {
  res.set("X-RateLimit-Limit", String(limit));
  res.set("X-RateLimit-Remaining", String(Math.max(0, remaining)));
 
  if (retryAfter) {
    res.set("Retry-After", String(retryAfter));
    res.set("X-RateLimit-Reset", String(
      Math.ceil(Date.now() / 1000) + retryAfter
    ));
  }
}
 
export function rateLimitMiddleware() {
  return async (req: Request, res: Response, next: NextFunction) => {
    const apiKey = extractApiKey(req);
 
    if (!apiKey) {
      res.status(401).json({ error: "API key obrigatória" });
      return;
    }
 
    const tier = getTier(apiKey);
    const identifier = `rl:${apiKey}`;
 
    try {
      // Camada 1: Token bucket para burst control
      const bucketResult = await tokenBucketLimit(
        `${identifier}:burst`,
        tier.burstSize,
        tier.refillRate
      );
 
      if (!bucketResult.allowed) {
        setRateLimitHeaders(
          res,
          tier.burstSize,
          bucketResult.remainingTokens,
          bucketResult.retryAfter
        );
        res.status(429).json({
          error: "Rate limit excedido (burst)",
          tier: tier.name,
          retryAfter: bucketResult.retryAfter,
        });
        return;
      }
 
      // Camada 2: Sliding window para limite por minuto
      const windowResult = await slidingWindowLimit(
        `${identifier}:min`,
        tier.requestsPerMinute,
        60_000
      );
 
      if (!windowResult.allowed) {
        setRateLimitHeaders(
          res,
          tier.requestsPerMinute,
          windowResult.remaining,
          windowResult.retryAfter
        );
        res.status(429).json({
          error: "Rate limit excedido (por minuto)",
          tier: tier.name,
          retryAfter: windowResult.retryAfter,
        });
        return;
      }
 
      // Camada 3: Limite diário com counter simples
      const dailyKey = `${identifier}:daily:${new Date().toISOString().slice(0, 10)}`;
      const dailyCount = await incrementDaily(dailyKey, tier.requestsPerDay);
 
      if (!dailyCount.allowed) {
        res.status(429).json({
          error: "Limite diário excedido",
          tier: tier.name,
          limit: tier.requestsPerDay,
        });
        return;
      }
 
      // Headers de sucesso
      setRateLimitHeaders(
        res,
        tier.requestsPerMinute,
        windowResult.remaining,
        null
      );
      res.set("X-RateLimit-Tier", tier.name);
 
      next();
    } catch (error) {
      // Fail open: se Redis cair, deixa passar
      console.error("[RateLimit] Erro:", error);
      next();
    }
  };
}
 
async function incrementDaily(
  key: string,
  limit: number
): Promise<{ allowed: boolean; count: number }> {
  const { default: redis } = await import("../lib/redis");
 
  const count = await redis.incr(key);
 
  if (count === 1) {
    // Expira à meia-noite + margem
    await redis.expire(key, 86400 + 3600);
  }
 
  return {
    allowed: count <= limit,
    count,
  };
}

Note o fail open no catch. Se o Redis cair, a API continua funcionando. Em produção, é melhor servir requests sem rate limiting do que derrubar tudo. Monitore esse cenário com alertas.

Servidor Express Completo

TYPESCRIPT
// src/server.ts
import express from "express";
import "dotenv/config";
import { rateLimitMiddleware } from "./middleware/rate-limit";
 
const app = express();
app.use(express.json());
 
// Health check sem rate limit
app.get("/health", (_req, res) => {
  res.json({ status: "ok", timestamp: new Date().toISOString() });
});
 
// Rate limit em todas as rotas da API
app.use("/api", rateLimitMiddleware());
 
// Rotas de exemplo
app.get("/api/users", (_req, res) => {
  res.json({
    users: [
      { id: 1, name: "Alice" },
      { id: 2, name: "Bob" },
    ],
  });
});
 
app.post("/api/data", (req, res) => {
  res.json({ received: req.body, processedAt: Date.now() });
});
 
// Rota para checar limites sem consumir
app.get("/api/rate-limit/status", async (req, res) => {
  const apiKey = req.headers["x-api-key"] as string;
  const { getTier } = await import("./config/tiers");
  const tier = getTier(apiKey || "");
 
  res.json({
    tier: tier.name,
    limits: {
      perMinute: tier.requestsPerMinute,
      perDay: tier.requestsPerDay,
      burstSize: tier.burstSize,
    },
  });
});
 
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`API rodando em http://localhost:${PORT}`);
});

Adicione o script de dev no package.json:

JSON
{
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js"
  }
}

Execute:

Bash
npm run dev

Testando o Rate Limiter

Teste rápido com curl:

Bash
# Request normal
curl -H "x-api-key: free_test123" http://localhost:3000/api/users
 
# Burst de requests para testar o limite
for i in $(seq 1 25); do
  echo "Request $i:"
  curl -s -o /dev/null -w "HTTP %{http_code}" \
    -H "x-api-key: free_test123" \
    http://localhost:3000/api/users
  echo ""
done

Após ~10 requests rápidos, você verá o 429 do burst. Após ~20 no minuto, o sliding window bloqueia.

Para um teste mais robusto:

TYPESCRIPT
// src/test/load-test.ts
async function loadTest() {
  const API_KEY = "free_test123";
  const URL = "http://localhost:3000/api/users";
  let allowed = 0;
  let blocked = 0;
 
  const requests = Array.from({ length: 50 }, (_, i) =>
    fetch(URL, { headers: { "x-api-key": API_KEY } }).then((res) => {
      if (res.status === 200) allowed++;
      if (res.status === 429) blocked++;
      return res.status;
    })
  );
 
  const results = await Promise.all(requests);
 
  console.log(`Total: ${results.length}`);
  console.log(`Permitidos: ${allowed}`);
  console.log(`Bloqueados: ${blocked}`);
  console.log(`Status codes:`, results);
}
 
loadTest();
Bash
npx tsx src/test/load-test.ts

Monitoramento em Produção

Rate limiting sem observabilidade é voar no escuro. Adicione métricas:

TYPESCRIPT
// src/middleware/rate-limit-metrics.ts
import redis from "../lib/redis";
 
interface RateLimitEvent {
  apiKey: string;
  tier: string;
  blocked: boolean;
  layer: "burst" | "minute" | "daily";
  timestamp: number;
}
 
export async function trackRateLimitEvent(event: RateLimitEvent) {
  const hourKey = `metrics:rl:${new Date().toISOString().slice(0, 13)}`;
 
  const pipeline = redis.pipeline();
 
  // Contador geral
  pipeline.hincrby(hourKey, "total", 1);
  pipeline.hincrby(hourKey, event.blocked ? "blocked" : "allowed", 1);
 
  // Por tier
  pipeline.hincrby(hourKey, `tier:${event.tier}:total`, 1);
 
  // Top bloqueados (para identificar abusadores)
  if (event.blocked) {
    pipeline.zincrby(
      `metrics:rl:blocked:${new Date().toISOString().slice(0, 10)}`,
      1,
      event.apiKey
    );
  }
 
  // TTL de 48h para limpeza automática
  pipeline.expire(hourKey, 172800);
 
  await pipeline.exec();
}
 
// Consulta para dashboard
export async function getMetrics(hour: string) {
  const data = await redis.hgetall(`metrics:rl:${hour}`);
  return {
    total: parseInt(data.total || "0"),
    allowed: parseInt(data.allowed || "0"),
    blocked: parseInt(data.blocked || "0"),
    blockRate: data.total
      ? ((parseInt(data.blocked || "0") / parseInt(data.total)) * 100).toFixed(2) + "%"
      : "0%",
  };
}

O pipeline é crucial. São 4-5 comandos Redis por request. Sem pipeline, são 4-5 round trips. Com pipeline, é 1 só.

Proteção Contra Ataques Distribuídos

Um atacante esperto usa múltiplas API keys. Adicione rate limiting por IP como camada extra:

TYPESCRIPT
// src/middleware/ip-limiter.ts
import { Request, Response, NextFunction } from "express";
import { slidingWindowLimit } from "../limiters/sliding-window";
 
export function ipRateLimit(maxPerMinute: number = 100) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const ip =
      req.headers["x-forwarded-for"]?.toString().split(",")[0].trim() ||
      req.socket.remoteAddress ||
      "unknown";
 
    try {
      const result = await slidingWindowLimit(
        `rl:ip:${ip}`,
        maxPerMinute,
        60_000
      );
 
      if (!result.allowed) {
        res.status(429).json({
          error: "Muitos requests deste IP",
          retryAfter: result.retryAfter,
        });
        return;
      }
 
      next();
    } catch {
      next(); // Fail open
    }
  };
}

Aplique antes do rate limit por API key:

TYPESCRIPT
app.use("/api", ipRateLimit(100));
app.use("/api", rateLimitMiddleware());

Duas camadas. IP primeiro, API key depois. Um atacante precisa furar ambas.

Minha Opinião Sincera

Vou ser direto sobre as alternativas.

express-rate-limit é o pacote mais popular. E é suficiente para projetos pequenos. Mas ele usa MemoryStore por padrão, que não funciona com múltiplas instâncias. Sim, tem o rate-limit-redis, mas a implementação é fixed window. Você herda todos os problemas que discutimos.

Upstash Ratelimit é excelente se você já usa Upstash. A SDK é limpa e o sliding window funciona bem. Mas é vendor lock-in. Quando você precisa de lógica customizada (limites por endpoint, custos diferentes por operação, rate limiting adaptativo), você bate no teto rápido.

API Gateways (Kong, AWS API Gateway) resolvem rate limiting na borda. É a abordagem certa para muitos cenários. Mas adiciona latência, custo e complexidade operacional. Para times pequenos, operar Kong em produção é mais trabalho do que implementar rate limiting no código.

A implementação que mostrei aqui tem uma vantagem que nenhuma biblioteca te dá: controle total. Você entende cada linha. Quando às 3h da manhã um cliente enterprise reclama que está sendo bloqueado indevidamente, você sabe exatamente onde olhar.

Redis como backend é a escolha certa para 95% dos casos. É rápido (sub-millisecond), distribuído por natureza e você provavelmente já tem um rodando. Os Lua scripts garantem atomicidade sem locks distribuídos.

O custo? Manutenção. Você é dono desse código. Não tem issue no GitHub para abrir. Mas para APIs de produção sérias, esse trade-off vale a pena.

Uma última coisa: sempre implemente fail open. Rate limiting é proteção, não funcionalidade core. Se o Redis cair por 30 segundos, sua API deve continuar servindo requests. Monitore, alerte, mas não derrube tudo.

Conclusão

Rate limiting inteligente tem três pilares. Algoritmo adequado ao caso de uso. Backend distribuído confiável. Observabilidade para saber o que está acontecendo.

O sliding window dá precisão. O token bucket dá flexibilidade para bursts. Tiers dinâmicos permitem monetizar sua API de forma justa. Redis garante que tudo funciona com N instâncias.

Comece com o sliding window. Adicione token bucket quando precisar de burst control. Implemente métricas desde o dia 1.

Rate limiting não é sobre bloquear usuários. É sobre proteger a experiência de todos os usuários.

Text
 
## Leia tambem
 
- [WebSockets vs SSE](/backend/websockets-vs-server-sent-events-quando-usar-cada-um)
- [PostgreSQL com PgBouncer](/backend/escalar-postgresql-connection-pooling-pgbouncer)
- [Design Patterns TypeScript](/backend/design-patterns-typescript-dev-senior)
Marcos Soares

Escrito por

Marcos Soares

Fullstack Developer · CEO da Agência Poti

Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.

Comentários

Participe da discussão

Seja o primeiro a comentar!

Continue Aprofundando

Guias de integração relacionados

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.