Objetivo da Aula
Ao concluir esta aula, você será capaz de:
Implementar rate limiting por usuário em memória com janela deslizante e resposta HTTP 429 correta
Validar inputs de API com verificações de tipo, tamanho e campos obrigatórios retornando erros descritivos
Configurar streaming SSE (Server-Sent Events) com Express para APIs de LLM que transmitem tokens em tempo real
Estruturar middlewares de autenticação, rate limiting e validação em cadeia com Express
Debugar problemas de streaming e conexão de LLM num servidor backend real
Por que isso importa
Um endpoint de LLM sem rate limiting é um endpoint que vai ser abusado. Um usuário (ou bot) pode fazer 1000 requests em 10 segundos, gerar uma fatura de API de $500 em minutos, e derrubar seu serviço para todos os outros usuários. Isso não é hipotético — aconteceu com dezenas de startups que lançaram sem pensar nisso.
O problema do streaming é diferente mas igualmente importante. Sem streaming, o usuário vê uma tela em branco por 3-8 segundos enquanto o LLM gera a resposta completa, e então tudo aparece de uma vez. Com streaming, o usuário vê tokens chegando progressivamente em ~200ms — a percepção de velocidade é radicalmente diferente mesmo com o mesmo tempo total de geração.
O terceiro problema é validação de input. LLMs são caros — um request com um message de 100.000 caracteres pode consumir dezenas de dollars em uma única chamada. Sem limites no input, um usuário malicioso (ou um bug no frontend) pode causar dano financeiro imediato.
Esta unidade te ensina a construir a camada de backend que torna uma API de LLM segura, responsiva e auditável para produção real.
Conceitos Fundamentais
Rate Limiting: Algoritmos e Implementações
Rate limiting controla quantas requisições um cliente pode fazer em um período de tempo. Existem vários algoritmos:
Fixed Window: conta requests em janelas de tempo fixas (ex: 10 req por minuto, contagem reseta a cada minuto exato). Problema: um cliente pode fazer 10 no final de um minuto e 10 no início do próximo (20 em 2 segundos reais).
Sliding Window: mais preciso. Conta requests nos últimos N segundos a partir do momento atual, não desde o início da janela fixa. Mais caro computacionalmente mas evita o problema de burst na borda da janela.
Token Bucket: cada cliente tem um “balde” com N tokens. Cada request consome 1 token. O balde enche na taxa de R tokens/segundo. Permite burst controlado mas limita taxa média.
Para uma API de LLM em produção, sliding window é o mais seguro contra abuso. Para implementação in-memory simples, fixed window é suficiente para começar.
Implementação Fixed Window em memória:
const rateLimits = new Map() // userId → { count, resetAt }
const LIMITE = 10
const JANELA_MS = 60_000 // 1 minuto
function verificarRateLimit(userId) {
const agora = Date.now()
const limiteUsuario = rateLimits.get(userId)
if (!limiteUsuario || agora >= limiteUsuario.resetAt) {
// Janela nova ou primeira request
rateLimits.set(userId, { count: 1, resetAt: agora + JANELA_MS })
return { permitido: true, restante: LIMITE - 1 }
}
if (limiteUsuario.count >= LIMITE) {
// Excedeu o limite
const retry_after = Math.ceil((limiteUsuario.resetAt - agora) / 1000)
return { permitido: false, retry_after }
}
// Dentro do limite
limiteUsuario.count++
return { permitido: true, restante: LIMITE - limiteUsuario.count }
}Header HTTP 429 correto:
HTTP/1.1 429 Too Many Requests
Retry-After: 45
Content-Type: application/json
{"erro": "Rate limit excedido", "retry_after_s": 45}O header Retry-After é importante — clientes bem implementados usam esse valor para esperar antes de tentar de novo.
Validação de Input: Defesa em Profundidade
Validação de input é a primeira linha de defesa. Para uma API de LLM, valide:
Campos obrigatórios e tipos:
function validar(body) {
const erros = []
// message: obrigatório, string, 1-2000 chars
if (!body.message) {
erros.push("Campo 'message' é obrigatório")
} else if (typeof body.message !== 'string') {
erros.push("Campo 'message' deve ser string")
} else if (body.message.length < 1 || body.message.length > 2000) {
erros.push(`Campo 'message' deve ter 1-2000 caracteres (atual: ${body.message.length})`)
}
// max_tokens: opcional, número, 1-4096
if (body.max_tokens !== undefined) {
if (!Number.isInteger(body.max_tokens)) {
erros.push("Campo 'max_tokens' deve ser inteiro")
} else if (body.max_tokens < 1 || body.max_tokens > 4096) {
erros.push(`Campo 'max_tokens' deve ser 1-4096 (atual: ${body.max_tokens})`)
}
}
// system: opcional, string, máx 1000 chars
if (body.system !== undefined) {
if (typeof body.system !== 'string') {
erros.push("Campo 'system' deve ser string")
} else if (body.system.length > 1000) {
erros.push(`Campo 'system' deve ter até 1000 caracteres (atual: ${body.system.length})`)
}
}
return erros
}Retorno de erro 400 descritivo:
{
"erro": "Validação falhou",
"detalhes": [
"Campo 'message' deve ter 1-2000 caracteres (atual: 0)",
"Campo 'max_tokens' deve ser 1-4096 (atual: 10000)"
]
}Erros descritivos são importantes para debugging pelo frontend e pelos desenvolvedores que consomem a API. “bad request” sem detalhes é inútil.
SSE Streaming: O Protocolo
Server-Sent Events (SSE) é um protocolo HTTP simples para streaming unidirecional (server → client). É mais simples que WebSockets para casos onde só o servidor precisa enviar dados continuamente.
Formato do protocolo SSE:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
data: {"tipo": "delta", "texto": "Olá"}\n\n
data: {"tipo": "delta", "texto": " mundo"}\n\n
data: [DONE]\n\nRegras do SSE: - Cada evento é data: <payload>\n\n (dois newlines no final) - Eventos vazios \n são heartbeats que mantêm a conexão aberta - data: [DONE] é a convenção para indicar fim do stream - Content-Type deve ser text/event-stream
No Express (Node.js):
Fundamento: Eventos de Streaming do LLM
O stream da API não é um fluxo de bytes brutos — é uma sequência de eventos tipados. Os principais: content_block_delta com delta.type === 'text_delta' carrega o texto incremental (o que você mostra ao usuário em tempo real); message_stop sinaliza o fim e carrega o usage com o total de tokens consumidos. Para repassar via SSE ao frontend, você converte cada evento de texto em data: {chunk}\n\n e envia um data: [DONE]\n\n ao receber o stop. O benefício do streaming é reduzir o Time to First Token percebido — o usuário vê a resposta começar em 500ms mesmo que a resposta completa leve 10s. A latência total não muda; a percepção de velocidade sim.
app.post('/v1/chat/stream', async (req, res) => {
// Headers para SSE
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.flushHeaders() // envia headers imediatamente
// Stream do Anthropic
const stream = await client.messages.stream({
model: 'claude-haiku-4-5-20251001',
max_tokens: 500,
messages: [{ role: 'user', content: req.body.message }]
})
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
// Envia cada chunk de texto como evento SSE
const chunk = JSON.stringify({ tipo: 'delta', texto: event.delta.text })
res.write(`data: ${chunk}\n\n`)
}
}
// Sinaliza fim do stream
res.write('data: [DONE]\n\n')
res.end()
})No cliente (JavaScript/browser):
const eventSource = new EventSource('/v1/chat/stream', {
method: 'POST', // EventSource padrão não suporta POST — use fetch com streams
})
// Alternativa com fetch + ReadableStream:
const response = await fetch('/v1/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'Olá!' }),
})
const reader = response.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value)
// Parse e exibe cada chunk
const linhas = chunk.split('\n').filter(l => l.startsWith('data: '))
for (const linha of linhas) {
const payload = linha.slice(6) // remove 'data: '
if (payload === '[DONE]') break
const dados = JSON.parse(payload)
console.log(dados.texto) // texto chegando em tempo real
}
}Autenticação com API Keys
API Keys são o método mais simples de autenticação para APIs de LLM. Em produção, as chaves são hashadas no banco de dados (você não armazena a chave em texto, só o hash):
// Em memória (para desenvolvimento)
const API_KEYS = new Map([
['dev-secret-123', { userId: 'user-1', plan: 'free' }],
])
// Em produção: banco de dados + hash
async function verificarApiKey(apiKey) {
const hash = crypto.createHash('sha256').update(apiKey).digest('hex')
const usuario = await db.query('SELECT * FROM api_keys WHERE key_hash = ?', [hash])
return usuario || null
}O header padrão para API keys é X-API-Key. Authorization Bearer é mais comum para JWTs: - X-API-Key: sk-prod-abc123 — chave de API direta - Authorization: Bearer <jwt> — JWT token (para auth de usuários)
Aprofundamento Técnico
Middleware Chain no Express
Express processa requests através de uma cadeia de middlewares. Cada middleware recebe (req, res, next) e ou responde ou chama next() para passar para o próximo:
// Chain: autenticar → rateLimiter → validarInput → handler
app.post('/v1/chat', autenticar, rateLimiter, validarInput, async (req, res) => {
// Chega aqui apenas se todos os middlewares chamaram next()
})
function autenticar(req, res, next) {
const apiKey = req.headers['x-api-key']
if (!apiKey) return res.status(401).json({ erro: 'API key ausente' })
const usuario = API_KEYS.get(apiKey)
if (!usuario) return res.status(401).json({ erro: 'API key inválida' })
req.usuario = usuario // compartilha dados entre middlewares via req
next() // passa para o próximo middleware
}
function rateLimiter(req, res, next) {
const { userId } = req.usuario // disponível porque autenticar() setou req.usuario
const resultado = verificarRateLimit(userId)
if (!resultado.permitido) {
return res.status(429).json({
erro: 'Rate limit excedido',
retry_after_s: resultado.retry_after,
})
}
next()
}Ordem importa: autenticar deve vir antes de rateLimiter porque o rate limit é por userId que só existe após autenticação.
Logging Estruturado para Debugging
APIs de LLM precisam de logging detalhado para debugging de produção:
app.post('/v1/chat', autenticar, rateLimiter, validarInput, async (req, res) => {
const startTime = Date.now()
const { userId } = req.usuario
const { message, max_tokens } = req.body
// Log de entrada
console.log(JSON.stringify({
event: 'llm_request',
timestamp: new Date().toISOString(),
userId,
message_length: message.length,
max_tokens,
}))
try {
const response = await client.messages.create({ ... })
// Log de saída com tokens e latência
console.log(JSON.stringify({
event: 'llm_response',
timestamp: new Date().toISOString(),
userId,
tokens_in: response.usage.input_tokens,
tokens_out: response.usage.output_tokens,
latencia_ms: Date.now() - startTime,
}))
res.json({ resposta: response.content[0].text })
} catch (err) {
console.error(JSON.stringify({
event: 'llm_error',
timestamp: new Date().toISOString(),
userId,
erro: err.message,
latencia_ms: Date.now() - startTime,
}))
res.status(500).json({ erro: 'Erro interno' })
}
})Gerenciamento de Memória no Rate Limiter
Um rate limiter in-memory que nunca limpa entradas antigas vaza memória. Para cada usuário que faz pelo menos uma request, você cria uma entrada no Map. Com 100.000 usuários únicos por dia, isso vira um problema:
// Limpeza periódica de entradas expiradas
setInterval(() => {
const agora = Date.now()
let removidos = 0
for (const [userId, limite] of rateLimits.entries()) {
if (agora >= limite.resetAt) {
rateLimits.delete(userId)
removidos++
}
}
if (removidos > 0) {
console.log(`Rate limiter: removidas ${removidos} entradas expiradas`)
}
}, 60_000) // limpa a cada minutoEm produção real, use Redis para rate limiting — compartilhado entre múltiplos servidores e com TTL nativo:
// Redis (em produção)
const redis = require('redis')
const client = redis.createClient()
async function verificarRateLimitRedis(userId) {
const key = `rate_limit:${userId}`
const count = await client.incr(key)
if (count === 1) {
// Primeira request: seta TTL de 60s
await client.expire(key, 60)
}
if (count > LIMITE) {
const ttl = await client.ttl(key)
return { permitido: false, retry_after: ttl }
}
return { permitido: true, restante: LIMITE - count }
}Exemplos Anotados
Exemplo 1: Rate Limiter Completo com Limpeza
// Rate limiter com:
// - Fixed window (simples e suficiente para começar)
// - Cleanup automático de entradas expiradas
// - Resposta 429 com Retry-After
const rateLimits = new Map()
const LIMITE_POR_MINUTO = 10
const JANELA_MS = 60_000
function verificarRateLimit(userId) {
const agora = Date.now()
const entrada = rateLimits.get(userId)
if (!entrada || agora >= entrada.resetAt) {
// Janela nova
rateLimits.set(userId, {
count: 1,
resetAt: agora + JANELA_MS,
})
return { permitido: true, restante: LIMITE_POR_MINUTO - 1 }
}
if (entrada.count >= LIMITE_POR_MINUTO) {
const retry_after = Math.ceil((entrada.resetAt - agora) / 1000)
return { permitido: false, retry_after }
}
entrada.count++
return { permitido: true, restante: LIMITE_POR_MINUTO - entrada.count }
}
// Middleware Express que usa verificarRateLimit
function rateLimiterMiddleware(req, res, next) {
const { userId, plan } = req.usuario
// Usuários pro têm limite maior
const limite = plan === 'pro' ? 100 : LIMITE_POR_MINUTO
const entrada = rateLimits.get(userId)
const agora = Date.now()
// Reutiliza verificarRateLimit mas com limite dinâmico
if (!entrada || agora >= entrada.resetAt) {
rateLimits.set(userId, { count: 1, resetAt: agora + JANELA_MS })
res.setHeader('X-RateLimit-Remaining', limite - 1)
return next()
}
if (entrada.count >= limite) {
const retry_after = Math.ceil((entrada.resetAt - agora) / 1000)
res.setHeader('Retry-After', retry_after)
return res.status(429).json({
erro: 'Rate limit excedido',
retry_after_s: retry_after,
plano_atual: plan,
limite_por_minuto: limite,
})
}
entrada.count++
res.setHeader('X-RateLimit-Remaining', limite - entrada.count)
next()
}
// Cleanup automático a cada minuto
setInterval(() => {
const agora = Date.now()
for (const [userId, entrada] of rateLimits.entries()) {
if (agora >= entrada.resetAt) rateLimits.delete(userId)
}
}, JANELA_MS)Exemplo 2: Endpoint Completo com Streaming SSE
const express = require('express')
const Anthropic = require('@anthropic-ai/sdk')
const app = express()
const client = new Anthropic.default()
app.use(express.json())
// === VALIDAÇÃO ===
function validarBody(body) {
const erros = []
if (!body.message) {
erros.push("Campo 'message' é obrigatório")
} else if (typeof body.message !== 'string') {
erros.push("Campo 'message' deve ser string")
} else if (body.message.length < 1) {
erros.push("Campo 'message' não pode ser vazio")
} else if (body.message.length > 2000) {
erros.push(`'message' muito longo: ${body.message.length} chars (máx 2000)`)
}
if (body.max_tokens !== undefined) {
if (!Number.isInteger(body.max_tokens) || body.max_tokens < 1 || body.max_tokens > 4096) {
erros.push("Campo 'max_tokens' deve ser inteiro entre 1 e 4096")
}
}
return erros
}
// === ENDPOINT STREAMING ===
app.post('/v1/chat', async (req, res) => {
// 1. Validação de input
const erros = validarBody(req.body)
if (erros.length > 0) {
return res.status(400).json({ erro: 'Validação falhou', detalhes: erros })
}
const { message, max_tokens = 500, system } = req.body
// 2. Headers SSE — enviados antes do primeiro token
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('Access-Control-Allow-Origin', '*') // CORS para SSE
res.flushHeaders() // garante que headers chegam ao cliente imediatamente
// 3. Configura params
const params = {
model: 'claude-haiku-4-5-20251001',
max_tokens,
messages: [{ role: 'user', content: message }],
}
if (system) params.system = system
// 4. Stream
try {
const stream = await client.messages.stream(params)
// itera sobre eventos do stream em tempo real
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
const chunk = JSON.stringify({
tipo: 'delta',
texto: event.delta.text,
})
res.write(`data: ${chunk}\n\n`)
}
if (event.type === 'message_stop') {
// Envia uso de tokens no evento final
const usage = stream.finalMessage?.usage
const metadata = JSON.stringify({
tipo: 'metadata',
tokens_input: usage?.input_tokens,
tokens_output: usage?.output_tokens,
})
res.write(`data: ${metadata}\n\n`)
}
}
// Sinaliza fim do stream para o cliente
res.write('data: [DONE]\n\n')
res.end()
} catch (err) {
// Mesmo em SSE, podemos enviar um evento de erro antes de fechar
const erro = JSON.stringify({ tipo: 'erro', mensagem: 'Erro interno do servidor' })
res.write(`data: ${erro}\n\n`)
res.end()
}
})
app.listen(3000, () => console.log('Servidor rodando em http://localhost:3000'))Padrões e Armadilhas
Padrões
Padrão 1: Rate limit por userId, não por IP IP pode ser compartilhado (VPN, NAT empresarial) ou facilmente trocado. Rate limit por userId (derivado da API key) é mais preciso e justo.
Padrão 2: Headers informativos no 429
res.setHeader('Retry-After', retry_after) // padrão HTTP
res.setHeader('X-RateLimit-Limit', LIMITE)
res.setHeader('X-RateLimit-Remaining', 0)
res.status(429).json({ erro: 'Rate limit', retry_after_s: retry_after })Clientes bem implementados usam esses headers para implementar backoff automático.
Padrão 3: res.flushHeaders() antes de iniciar o stream Se você não chamar flushHeaders(), o Express pode bufferizar os headers até que haja dados suficientes para enviar. O cliente ficará esperando sem receber nada por vários segundos.
Padrão 4: Try-catch em todo handler async do Express
// SEM try-catch: Express 4.x não captura erros em async automaticamente
app.post('/v1/chat', async (req, res) => {
const r = await client.messages.create(...) // se jogar erro, o servidor trava
})
// COM try-catch: sempre trate erros em handlers async
app.post('/v1/chat', async (req, res) => {
try {
const r = await client.messages.create(...)
} catch (err) {
res.status(500).json({ erro: 'Erro interno' })
}
})Armadilhas
⚠️ Armadilha 1: Não limpar entradas expiradas do Map de rate limit
// PROBLEMA: cada usuário único cria uma entrada que nunca é removida
// Com 1M usuários únicos por mês: Map com 1M entradas (leak de memória)
// SOLUÇÃO: cleanup periódico (veja seção de Aprofundamento Técnico)
setInterval(() => {
for (const [k, v] of rateLimits.entries()) {
if (Date.now() >= v.resetAt) rateLimits.delete(k)
}
}, 60_000)⚠️ Armadilha 2: Content-Type errado para SSE
// ERRADO: application/json fecha a conexão após o primeiro write
res.setHeader('Content-Type', 'application/json')
// CORRETO: text/event-stream mantém a conexão aberta
res.setHeader('Content-Type', 'text/event-stream')⚠️ Armadilha 3: Validação que não diferencia string vazia de null
// ERRADO: só checa falsiness — strings vazias passam
if (!req.body.message) { ... }
// message === "" → !message === true → captura
// message === undefined → captura
// OK nesse caso, mas se quiser diferenciar:
// MAIS PRECISO: checa tipo E comprimento
if (typeof req.body.message !== 'string' || req.body.message.length === 0) {
return res.status(400).json({ erro: "'message' deve ser string não-vazia" })
}⚠️ Armadilha 4: Esquecer de chamar res.end() no streaming
// SEM res.end(): a conexão fica aberta indefinidamente (leak de conexão)
res.write('data: [DONE]\n\n')
// res.end() // ← ESQUECEU!
// COM res.end(): fecha corretamente
res.write('data: [DONE]\n\n')
res.end() // sinaliza ao cliente que o stream terminou⚠️ Armadilha 5: Rate limit compartilhado entre ambientes dev e prod
// ERRADO: mesma chave de API de dev no banco de prod
// → rate limit de dev esgota limite de prod
// CORRETO: ambientes separados com API keys separadas, ou
// rate limit que considera o ambiente como parte da chave
const key = `rate_limit:${env}:${userId}`Se não for realizar o laboratório, pule para o próximo capítulo.
Ponte para o Lab
O starter tem 3 TODOs que completam o servidor Express:
TODO 1 — Em rateLimiter(): implemente rate limiting por req.usuario.userId usando o Map rateLimits. Use fixed window de 10 requests por minuto. Se rateLimits não tiver entrada para o userId ou a entrada expirou (Date.now() >= resetAt), crie nova entrada com count: 1 e resetAt: Date.now() + 60_000. Se tiver entrada e count >= 10, retorne 429 com {'erro': 'Rate limit excedido', 'retry_after_s': X}. Senão, incremente count e chame next(). Seção de referência: Conceitos Fundamentais → “Rate Limiting: Algoritmos e Implementações”.
TODO 2 — Em validarInput(): expanda a validação além do if (!req.body.message) que já existe. Adicione: verificação de tipo string, comprimento 1-2000 para message, e validações opcionais para max_tokens (inteiro 1-4096) e system (string máx 1000 chars). Retorne 400 com array de erros descritivos. Seção de referência: Conceitos Fundamentais → “Validação de Input”.
TODO 3 — No handler do app.post('/v1/chat'): substitua o bloco de resposta simples por SSE. Defina os headers Content-Type: text/event-stream, Cache-Control: no-cache. Use client.messages.stream(params) e itere sobre os eventos. Para cada content_block_delta com delta.type === 'text_delta', escreva res.write(data: ${JSON.stringify({texto: event.delta.text})}). No final, escreva res.write('data: [DONE]\n\n') e res.end(). Seção de referência: Conceitos Fundamentais → “SSE Streaming: O Protocolo” e Exemplos Anotados → Exemplo 2.
Após implementar, teste com:
# Sem streaming
curl -X POST http://localhost:3000/v1/chat \
-H "Content-Type: application/json" \
-H "X-API-Key: dev-secret-123" \
-d '{"message": "Olá!"}'
# Com streaming (você verá tokens chegando progressivamente)
curl -X POST http://localhost:3000/v1/chat \
-H "Content-Type: application/json" \
-H "X-API-Key: dev-secret-123" \
-N \
-d '{"message": "Escreva um poema curto sobre código."}'Agora você está pronto para o lab.