mozak.tech Engenharia de IA Corporativa 88%

Parte II — Acesso Programático e Instrução de Modelos

2.6 — IA no Backend: Endpoints, Limitação de Taxa e Streaming (Backend + IA)

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\n

Regras 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 minuto

Em 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}`
⚗ Laboratório prático — mozak.tech
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.