Arquitetura do Rate Limiting
Arquitetura: API Rate Limiting & Security Headers
O sistema de rate limiting segue a arquitetura hexagonal do ForestWatch, com separação clara entre port (interface RateLimitStore) e adapter (InMemoryRateLimitStore). O pacote @fw/rate-limit é independente de framework e se integra ao Next.js através dos wrappers enhanceAction e enhanceRouteHandler do pacote @kit/next.
Diagrama de Arquitetura
┌─────────────────────────────────────────────────────────────────────┐
│ Camada de Apresentação │
│ │
│ ┌──────────────┐ ┌──────────────────┐ ┌───────────────────────┐ │
│ │ API Routes │ │ Server Actions │ │ proxy.ts (middleware)│ │
│ │ /api/* │ │ 'use server' │ │ Security Headers │ │
│ └──────┬───────┘ └────────┬──────────┘ └───────────┬───────────┘ │
└─────────┼───────────────────┼─────────────────────────┼─────────────┘
│ │ │
┌─────────┼───────────────────┼─────────────────────────┼─────────────┐
│ ▼ ▼ │ │
│ ┌──────────────────────────────────────┐ │ │
│ │ @kit/next (Wrappers) │ │ │
│ │ enhanceRouteHandler enhanceAction │ │ │
│ │ ┌─────────────────────────────────┐ │ │ │
│ │ │ 1. Rate Limit Check │ │ │ │
│ │ │ 2. CAPTCHA Verification │ │ │ │
│ │ │ 3. Auth Check │ │ │ │
│ │ │ 4. Schema Validation │ │ │ │
│ │ │ 5. Handler Execution │ │ │ │
│ │ └─────────────────────────────────┘ │ │ │
│ └──────────────┬───────────────────────┘ │ │
└─────────────────┼─────────────────────────────────────┼─────────────┘
│ │
┌─────────────────┼─────────────────────────────────────┼─────────────┐
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ @fw/rate-limit │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌──────────────┐ ┌────────────────┐ │ │
│ │ │ rate-limiter.ts │ │ headers.ts │ │ security- │ │ │
│ │ │ (Core Logic) │ │ (RL Headers)│ │ headers.ts │ │ │
│ │ └────────┬─────────┘ └──────────────┘ └────────────────┘ │ │
│ │ │ │ │
│ │ ┌────────▼──────────────────────────────────────────────┐ │ │
│ │ │ RateLimitStore (PORT - Interface) │ │ │
│ │ │ check(key, limit, windowMs) → RateLimitResult │ │ │
│ │ └────────┬──────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌────────▼──────────────────────────────────────────────┐ │ │
│ │ │ InMemoryRateLimitStore (ADAPTER) │ │ │
│ │ │ Map<string, WindowEntry> │ │ │
│ │ │ Cleanup automático a cada 60s, TTL de 5 minutos │ │ │
│ │ └───────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘Estrutura do Pacote
packages/fw/rate-limit/
├── src/
│ ├── index.ts # API pública (re-exports)
│ ├── rate-limit-store.ts # Port: interface RateLimitStore
│ ├── in-memory-store.ts # Adapter: InMemoryRateLimitStore
│ ├── rate-limiter.ts # Core: tiers, resolveConfig, checkRateLimit
│ ├── client-identifier.ts # Utilitário: extração de IP
│ ├── headers.ts # Utilitário: headers de rate limit
│ ├── security-headers.ts # Constantes e aplicação de security headers
│ ├── store-instance.ts # Singleton do store
│ └── __tests__/ # 56 testes (PBT + unit + integration)
├── package.json
├── tsconfig.json
└── vitest.config.tsPadrão Hexagonal: Port & Adapter
Port: RateLimitStore
A interface define o contrato para qualquer implementação de armazenamento de contadores:
export interface RateLimitResult {
allowed: boolean;
limit: number;
remaining: number;
resetAt: number; // Unix timestamp (segundos)
retryAfter?: number; // Segundos até reset (só quando bloqueado)
}
export interface RateLimitStore {
check(key: string, limit: number, windowMs: number): Promise<RateLimitResult>;
cleanup(): void;
destroy(): void;
}Adapter: InMemoryRateLimitStore
Implementação baseada em Map com sliding window:
export class InMemoryRateLimitStore implements RateLimitStore {
private windows: Map<string, WindowEntry> = new Map();
// Cleanup automático a cada 60s, TTL de 5 min
}Extensibilidade
Para adicionar um adapter Redis/Upstash no futuro, basta implementar a interface RateLimitStore:
// Exemplo futuro (não implementado)
export class UpstashRateLimitStore implements RateLimitStore {
constructor(private redis: Redis) {}
async check(key, limit, windowMs) { /* Redis INCR + EXPIRE */ }
cleanup() { /* Redis TTL cuida disso */ }
destroy() { /* Fecha conexão */ }
}Fluxo de Dados: Route Handler
1. Requisição HTTP chega ao Next.js
2. proxy.ts aplica SECURITY_HEADERS
3. enhanceRouteHandler verifica se rateLimit está configurado
4. Resolve config: tier defaults + overrides
→ resolveRateLimitConfig({ tier: 'strict' }, 'route:/api/auth/check-email')
→ { maxRequests: 5, windowMs: 60000, keyPrefix: 'route:/api/auth/check-email' }
5. Extrai IP: x-forwarded-for → x-real-ip → '127.0.0.1'
6. Verifica rate limit no store (singleton)
→ store.check('route:/api/auth/check-email:10.0.0.1', 5, 60000)
7a. Bloqueado → 429 + headers RL
7b. Erro no store → Fail-open (continua)
7c. Permitido → Fluxo existente: CAPTCHA → Auth → Schema → Handler
8. Resposta com headers RL anexadosFluxo de Dados: Server Action
1. Client invoca server action
2. enhanceAction verifica se rateLimit está configurado
3. Resolve config com tier defaults
4. Extrai IP via next/headers
5. Verifica rate limit
→ Bloqueado: throw Error('Too many requests...')
→ Erro no store: Fail-open (continua)
→ Permitido: Fluxo existente: Schema → CAPTCHA → Auth → ActionPadrão Singleton
O store é compartilhado entre todas as requisições:
let instance: RateLimitStore | null = null;
export function getRateLimitStore(): RateLimitStore {
if (!instance) {
instance = new InMemoryRateLimitStore();
}
return instance;
}Garante que todos os endpoints compartilham o mesmo Map de contadores e o cleanup interval roda uma única vez.
Mecanismo de Cleanup
O InMemoryRateLimitStore executa limpeza automática para evitar vazamento de memória:
- Intervalo: 60 segundos (configurável no construtor)
- TTL: 5 minutos (generoso para cobrir qualquer janela)
- Complexidade: O(n) onde n = número de entradas no Map
- Comportamento: Remove entradas com
now - startTime > 5 min
Pontos de Integração
| Ponto | Arquivo | Mudança |
|---|---|---|
enhanceRouteHandler | packages/next/src/routes/index.ts | Rate limit como primeiro passo |
enhanceAction | packages/next/src/actions/index.ts | Rate limit como primeiro passo |
proxy.ts | apps/web/proxy.ts | Security headers globais |
api.config.ts | apps/web/config/api.config.ts | Configuração centralizada de tiers |
Decisões Arquiteturais
1. In-Memory vs Redis
| Critério | In-Memory (atual) | Redis/Upstash (futuro) |
|---|---|---|
| Latência | < 0.01ms | ~1-5ms |
| Persistência | Não (reseta no deploy) | Sim |
| Multi-instance | Não (por processo) | Sim (compartilhado) |
| Dependências | Nenhuma | Redis client |
| Custo | Zero | Variável |
Decisão: In-memory primeiro. O Vercel roda single-instance por função serverless, então o Map funciona bem.
2. Fail-Open vs Fail-Closed
Decisão: Fail-open. Disponibilidade é mais importante que enforcement estrito. Um rate limiter quebrado não deve derrubar a aplicação.
3. Opt-in vs Global
Decisão: Opt-in por endpoint. Preserva compatibilidade retroativa e permite controle granular.
4. Rate Limit antes de Auth
Decisão: Rate limit é verificado antes de autenticação. Protege contra ataques de força bruta que tentam sobrecarregar o sistema de auth.
5. Identificação por IP
Decisão: IP como identificador primário via headers. Simples e eficaz. User ID pode ser adicionado como identificador secundário no futuro.
Performance
| Operação | Complexidade | Latência |
|---|---|---|
store.check() | O(1) | < 0.01ms |
getClientIdentifier() | O(1) | < 0.01ms |
resolveRateLimitConfig() | O(1) | < 0.01ms |
rateLimitHeaders() | O(1) | < 0.01ms |
cleanup() | O(n) | Depende do Map |
| Total por requisição | O(1) | < 0.1ms |