Por que rate limiting importa
Sem rate limiting, uma única origem pode:
- Esgotar os recursos do servidor com flood de requests
- Gerar custos inesperados em APIs de terceiros (OpenAI, Stripe, SMS)
- Realizar brute-force em endpoints de autenticação
- Causar indisponibilidade para outros usuários
Rate limiting é uma das defesas mais básicas e eficazes.
@nestjs/throttler: o ponto de partida
Para a maioria dos projetos, o módulo oficial resolve:
// app.module.ts
@Module({
imports: [
ThrottlerModule.forRoot([
{
name: "short",
ttl: 1000, // 1 segundo
limit: 10, // 10 requests por segundo
},
{
name: "long",
ttl: 60000, // 1 minuto
limit: 100, // 100 requests por minuto
},
]),
],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}Para usar Redis como storage (necessário com múltiplas instâncias):
pnpm add @nest-lab/throttler-storage-redisThrottlerModule.forRoot({
throttlers: [{ ttl: 60000, limit: 100 }],
storage: new ThrottlerStorageRedis(redisClient),
})Limites diferentes por rota
@Controller("auth")
export class AuthController {
// Login: limite mais restrito contra brute-force
@Post("login")
@Throttle({ default: { ttl: 60000, limit: 5 } })
async login(@Body() dto: LoginDto) {}
// Refresh token: limite moderado
@Post("refresh")
@Throttle({ default: { ttl: 60000, limit: 20 } })
async refresh(@Body() dto: RefreshDto) {}
// Rota pública sem limite
@Get("status")
@SkipThrottle()
status() {}
}Sliding window com Redis: implementação manual
O @nestjs/throttler usa fixed window por padrão. Sliding window é mais preciso e evita o "burst" no início de cada janela.
@Injectable()
export class SlidingWindowRateLimiter {
constructor(private readonly redis: Redis) {}
async isAllowed(
key: string,
windowMs: number,
maxRequests: number
): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> {
const now = Date.now();
const windowStart = now - windowMs;
// Script Lua: executa atomicamente no Redis
const script = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local windowStart = tonumber(ARGV[2])
local maxRequests = tonumber(ARGV[3])
local ttl = tonumber(ARGV[4])
-- Remove requests fora da janela
redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)
-- Conta requests na janela atual
local count = redis.call('ZCARD', key)
if count < maxRequests then
-- Adiciona o request atual
redis.call('ZADD', key, now, now)
redis.call('PEXPIRE', key, ttl)
return {1, maxRequests - count - 1}
else
return {0, 0}
end
`;
const [allowed, remaining] = (await this.redis.eval(
script,
1,
key,
now.toString(),
windowStart.toString(),
maxRequests.toString(),
windowMs.toString()
)) as [number, number];
return {
allowed: allowed === 1,
remaining,
resetAt: new Date(now + windowMs),
};
}
}Guard usando o sliding window
@Injectable()
export class RateLimitGuard implements CanActivate {
constructor(private readonly limiter: SlidingWindowRateLimiter) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
// Limite por usuário autenticado ou por IP
const identifier = request.user?.id ?? request.ip;
const route = `${request.method}:${request.route?.path}`;
const key = `ratelimit:${route}:${identifier}`;
const { allowed, remaining, resetAt } = await this.limiter.isAllowed(
key,
60_000, // 1 minuto
100 // 100 requests
);
// Headers informativos para o cliente
response.setHeader("X-RateLimit-Remaining", remaining);
response.setHeader("X-RateLimit-Reset", resetAt.toISOString());
if (!allowed) {
throw new HttpException(
{
statusCode: 429,
message: "Muitas requisições. Tente novamente em breve.",
retryAfter: resetAt.toISOString(),
},
HttpStatus.TOO_MANY_REQUESTS
);
}
return true;
}
}Limites múltiplos em cascata
Para endpoints críticos, aplique limites em múltiplas janelas:
async isAllowedMulti(identifier: string): Promise<boolean> {
const limits = [
{ window: 1_000, max: 5 }, // 5 por segundo
{ window: 60_000, max: 100 }, // 100 por minuto
{ window: 3_600_000, max: 1000 }, // 1000 por hora
];
for (const { window, max } of limits) {
const key = `ratelimit:${window}:${identifier}`;
const { allowed } = await this.isAllowed(key, window, max);
if (!allowed) return false;
}
return true;
}Rate limiting em endpoints de autenticação
Endpoints de login e recuperação de senha merecem tratamento especial:
@Injectable()
export class AuthRateLimitGuard implements CanActivate {
constructor(private readonly limiter: SlidingWindowRateLimiter) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const ip = request.ip;
const email = request.body?.email;
// Limite por IP
const byIp = await this.limiter.isAllowed(`auth:ip:${ip}`, 300_000, 10);
if (!byIp.allowed) throw new HttpException("Rate limit excedido", 429);
// Limite por email (se informado)
if (email) {
const byEmail = await this.limiter.isAllowed(`auth:email:${email}`, 300_000, 5);
if (!byEmail.allowed) throw new HttpException("Rate limit excedido", 429);
}
return true;
}
}Isso evita que um atacante tente múltiplas senhas para o mesmo email de IPs diferentes.
Conclusão
Para a maioria dos projetos, @nestjs/throttler com storage Redis resolve bem. Para casos que precisam de sliding window real, controle por múltiplos identificadores ou limites em cascata, a implementação manual com Lua script no Redis é a abordagem mais precisa e performática.
O ponto crítico é sempre usar Redis (ou equivalente) como storage: nunca mantenha estado de rate limiting em memória quando há mais de uma instância da aplicação.