Quando sua API retorna um erro, o consumidor precisa de duas coisas: saber o que aconteceu, e saber o que fazer. A maioria das APIs falha em entregar ambas.

O padrão mais comum que vejo em codebases: um catch genérico que retorna 500 com uma mensagem vaga. O consumidor não sabe se é problema dele, da rede, ou do servidor. Não tem como retry com segurança. Não tem como reportar o bug com contexto.

O formato de erro que funciona

Padronize a resposta de erro. Sempre o mesmo shape, independente do tipo de falha:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Email inválido",
    "details": [
      {
        "field": "email",
        "message": "Formato de email inválido",
        "value": "marc@"
      }
    ],
    "request_id": "req_abc123",
    "timestamp": "2026-03-22T14:30:00Z"
  }
}

code é uma string legível por máquina. message é legível por humano. details contém contexto específico. request_id permite correlacionar com logs. timestamp ajuda em debugging temporal.

Mapa de status codes

Cada status code tem um significado. Use-os corretamente:

  • 400 Bad Request: o cliente enviou dados inválidos. Inclua o que está errado.
  • 401 Unauthorized: o cliente não autenticou. Retorne headers de autenticação.
  • 403 Forbidden: o cliente autenticou mas não tem permissão.
  • 404 Not Found: o recurso não existe. Diferencie de 403 em recursos sensíveis.
  • 409 Conflict: conflito de estado, email duplicado, versão desatualizada.
  • 422 Unprocessable: dados validados mas com regras de negócio violadas.
  • 429 Too Many Requests: rate limit. Retorne headers Retry-After.
  • 500 Internal Error: falha no servidor. Nunca exponha stack traces.
  • 503 Service Unavailable: serviço indisponível temporariamente.

Implementação em Express/Nest

Um middleware de erro centralizado evita repetição e garante consistência:

class AppError extends Error {
  constructor(
    public readonly code: string,
    public readonly statusCode: number,
    message: string,
    public readonly details?: unknown[]
  ) {
    super(message)
  }
}

// Middleware de erro
function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      error: {
        code: err.code,
        message: err.message,
        details: err.details,
        request_id: req.id,
        timestamp: new Date().toISOString()
      }
    })
  }

  console.error('Unhandled error:', err)
  return res.status(500).json({
    error: {
      code: 'INTERNAL_ERROR',
      message: 'Erro interno do servidor',
      request_id: req.id,
      timestamp: new Date().toISOString()
    }
  })
}

O que não expor em erros

Nunca retorne stack traces, paths de arquivos, ou detalhes de implementação. Em produção, esses dados são vetores de ataque. Logue tudo internamente, retorne apenas o necessário para o consumidor corrigir o problema.

Para erros de validação, mostre os campos com problema. Para erros de autenticação, não diga se o usuário existe ou não. Para erros de banco, retorne uma mensagem genérica e logue o detalhe.

Logging de erros

Cada erro deve gerar um log com contexto: request_id, user_id, endpoint, payload (sanitizado), e stack trace completa. Ferramentas como Sentry ou Datadog agregam esses logs e permitem busca por código de erro, frequência, e impacto.

O padrão request_id no header X-Request-Id permite ao consumidor reportar exatamente qual requisição falhou, e ao time encontrar o log correspondente.