Padrões de tratamento de erros em APIs Go que padronizam erros tipados, códigos de status HTTP, IDs de requisição e mensagens seguras sem vazar internos.

Quando cada endpoint relata falhas de forma diferente, os clientes param de confiar na sua API. Uma rota retorna { \"error\": \"not found\" }, outra retorna { \"message\": \"missing\" }, e uma terceira envia texto simples. Mesmo que o significado seja parecido, o código do cliente passa a adivinhar o que aconteceu.
O custo aparece rápido. Times constroem lógica de parsing frágil e adicionam casos especiais por endpoint. Retentativas ficam arriscadas porque o cliente não consegue distinguir “tente novamente mais tarde” de “seu input está errado”. Tickets de suporte aumentam porque o cliente só vê uma mensagem vaga, e seu time não consegue ligar isso facilmente a uma linha de log do servidor.
Um cenário comum: um app mobile chama três endpoints durante o cadastro. O primeiro retorna HTTP 400 com um mapa de erro por campo, o segundo retorna HTTP 500 com um stack trace em string, e o terceiro retorna HTTP 200 com { \"ok\": false }. A equipe do app entrega três handlers de erro diferentes, e seu time de backend ainda recebe relatórios como “o cadastro às vezes falha” sem uma pista clara de onde começar.
O objetivo é um contrato previsível. Clientes devem conseguir ler com confiança o que aconteceu: se foi culpa deles ou sua, se vale a pena tentar de novo e um request ID que possam colar no suporte.
Nota de escopo: isso foca em APIs HTTP JSON (não gRPC), mas as mesmas ideias se aplicam a qualquer lugar onde você retorna erros para outros sistemas.
Escolha um contrato claro para erros e faça com que todo endpoint o obedeça. “Consistente” significa a mesma forma JSON, o mesmo significado dos campos e o mesmo comportamento, não importa qual handler falhou. Quando isso acontece, os clientes param de adivinhar e começam a tratar erros.
Um contrato útil ajuda o cliente a decidir o que fazer a seguir. Para a maioria das aplicações, cada resposta de erro deve responder três perguntas:
Um conjunto prático de regras:
Decida desde o início o que nunca deve aparecer nas respostas. Itens comuns que nunca devem vazar incluem fragmentos SQL, stack traces, nomes internos de hosts, segredos e strings de erro brutas de dependências.
Mantenha uma divisão limpa: uma mensagem curta para o usuário (segura, educada, acionável) e detalhes internos (erro completo, stack e contexto) mantidos em logs. Por exemplo, “Não foi possível salvar suas alterações. Por favor, tente novamente.” é seguro. “pq: duplicate key value violates unique constraint users_email_key” não é.
Quando todo endpoint segue o mesmo contrato, o cliente pode construir um único handler de erro e reutilizá-lo em todos os lugares.
Clientes só conseguem tratar erros de forma limpa se todo endpoint responder no mesmo formato. Escolha um envelope JSON e mantenha-o estável.
Um padrão prático é um objeto error mais um request_id no topo:
{
\"error\": {
\"code\": \"VALIDATION_FAILED\",
\"message\": \"Some fields are invalid.\",
\"details\": {
\"fields\": {
\"email\": \"must be a valid email address\"
}
}
},
\"request_id\": \"req_01HV...\"
}
O status HTTP dá a categoria ampla (400, 401, 409, 500). O error.code legível por máquina dá o caso específico que o cliente pode distinguir. Essa separação é importante porque muitos problemas diferentes compartilham o mesmo status. Um app mobile pode mostrar UIs diferentes para EMAIL_TAKEN vs WEAK_PASSWORD, mesmo que ambos sejam 400.
Mantenha error.message segura e voltada para humanos. Deve ajudar o usuário a corrigir o problema, mas nunca vazar internos (SQL, stack traces, nomes de provedores, caminhos de arquivos).
Campos opcionais são úteis quando permanecem previsíveis:
details.fields como um mapa de campo para mensagem.details.retry_after_seconds.details.docs_hint como texto simples (não uma URL).Para compatibilidade retroativa, trate os valores de error.code como parte do contrato da API. Adicione novos códigos sem mudar significados antigos. Apenas adicione campos opcionais, e presuma que clientes vão ignorar campos que não reconhecem.
O tratamento de erros fica bagunçado quando cada handler inventa sua própria maneira de sinalizar falha. Um pequeno conjunto de erros tipados resolve isso: handlers retornam tipos de erro conhecidos, e uma camada de resposta transforma-os em respostas consistentes.
Um conjunto prático inicial cobre a maioria dos endpoints:
O importante é estabilidade no nível superior, mesmo se a causa raiz mudar. Você pode encapsular erros de baixo nível (SQL, rede, parsing JSON) enquanto ainda retorna o mesmo tipo público que o middleware pode detectar.
type NotFoundError struct {
Resource string
ID string
Err error // private cause
}
func (e NotFoundError) Error() string { return "not found" }
func (e NotFoundError) Unwrap() error { return e.Err }
No seu handler, retorne NotFoundError{Resource: \"user\", ID: id, Err: err} em vez de vazar sql.ErrNoRows diretamente.
Para checar erros, prefira errors.As para tipos customizados e errors.Is para erros-sentença. Erros-sentença (como var ErrUnauthorized = errors.New(\"unauthorized\")) funcionam para casos simples, mas tipos customizados vencem quando você precisa de contexto seguro (por exemplo, qual recurso estava faltando) sem mudar seu contrato público.
Seja rígido sobre o que você anexa:
Err subjacente, info de stack, erros SQL brutos, tokens, dados do usuário.Essa separação permite ajudar clientes sem expor internos.
Uma vez que você tenha erros tipados, o próximo trabalho é chato mas essencial: o mesmo tipo de erro deve sempre produzir o mesmo status HTTP. Clientes vão construir lógica em cima disso.
Um mapeamento prático que funciona para a maioria das APIs:
| Tipo de erro (exemplo) | Status | Quando usar |
|---|---|---|
| BadRequest (JSON malformado, query param obrigatório ausente) | 400 | A requisição não é válida em nível de protocolo ou formato. |
| Unauthenticated (token ausente/inválido) | 401 | O cliente precisa se autenticar. |
| Forbidden (sem permissão) | 403 | Auth válida, mas acesso não permitido. |
| NotFound (ID do recurso não existe) | 404 | O recurso requisitado não está lá (ou você decide esconder existência). |
| Conflict (constraint única, mismatch de versão) | 409 | A requisição é bem formada, mas conflita com o estado atual. |
| ValidationFailed (regras de campo) | 422 | A forma está ok, mas a validação de negócio falha (formato de email, tamanho mínimo). |
| RateLimited | 429 | Muitas requisições em uma janela de tempo. |
| Internal (erro desconhecido) | 500 | Bug ou falha inesperada. |
| Unavailable (dependência caída, timeout, manutenção) | 503 | Problema temporário no servidor. |
Duas distinções que evitam muita confusão:
Orientação sobre retry importa:
Um request ID é um valor curto e único que identifica uma chamada de API de ponta a ponta. Se clientes conseguem vê-lo em toda resposta, suporte fica simples: “Me envie o request ID” muitas vezes é suficiente para encontrar os logs exatos e a falha exata.
Esse hábito vale tanto para respostas de sucesso quanto de erro.
Use uma regra clara: se o cliente enviar um request ID, mantenha-o. Se não, crie um.
X-Request-Id).Coloque o request ID em três lugares:
request_id no seu esquema padrão)Para endpoints em lote ou jobs em background, mantenha um request ID pai. Exemplo: um cliente faz upload de 200 linhas, 12 falham validação e você enfileira trabalho. Retorne um único request_id para toda a chamada e inclua um parent_request_id em cada job e em cada erro por item. Assim, você consegue rastrear “um upload” mesmo quando ele se espalha em muitas tarefas.
Clientes precisam de uma resposta de erro clara e estável. Seus logs precisam da verdade bagunçada. Mantenha esses dois mundos separados: retorne uma mensagem pública segura e um código de erro público ao cliente, enquanto loga a causa real, o stack e o contexto no servidor.
Registre um evento estruturado para cada resposta de erro, pesquisável por request_id.
Campos que valem a pena manter consistentes:
Armazene detalhes internos apenas em logs do servidor (ou em um repositório interno de erros). O cliente nunca deve ver erros de banco de dados brutos, texto de queries, stack traces ou mensagens de provedores. Se você roda vários serviços, um campo interno como source (api, db, auth, upstream) pode acelerar a triagem.
Monitore endpoints barulhentos e erros de taxa limitada. Se um endpoint pode produzir o mesmo 429 ou 400 milhares de vezes por minuto, evite spam nos logs: amostre eventos repetidos ou reduza a severidade para erros esperados enquanto ainda os conta nas métricas.
Métricas pegam problemas mais cedo que logs. Acompanhe contagens agrupadas por status HTTP e código de erro, e alerte em picos súbitos. Se RATE_LIMITED pular 10x após um deploy, você vai ver rápido mesmo se os logs forem amostrados.
A maneira mais fácil de tornar erros consistentes é parar de tratá-los “em todo lugar” e roteá-los por um pequeno pipeline. Esse pipeline decide o que o cliente vê e o que você mantém para logs.
Comece com um pequeno conjunto de códigos de erro que clientes possam depender (por exemplo: INVALID_ARGUMENT, NOT_FOUND, UNAUTHORIZED, CONFLICT, INTERNAL). Encapsule-os em um erro tipado que exponha apenas campos públicos seguros (code, mensagem segura, detalhes opcionais como qual campo está errado). Mantenha causas internas privadas.
Depois implemente uma função tradutora que converta qualquer erro em (statusCode, responseBody). É aqui que erros tipados mapeiam para status HTTP, e erros desconhecidos viram um 500 seguro.
Em seguida, adicione middleware que:
request_idUm panic nunca deve despejar stack traces para o cliente. Retorne um 500 normal com uma mensagem genérica, e registre o panic completo com o mesmo request_id.
Finalmente, mude seus handlers para que retornem um error em vez de escrever a resposta diretamente. Um wrapper pode chamar o handler, rodar o tradutor e escrever o JSON no formato padrão.
Uma checklist compacta:
Tests dourados importam porque travam o contrato. Se alguém mais tarde mudar uma mensagem ou status, os testes falham antes que clientes sejam surpreendidos.
Imagine um endpoint: um app cliente cria um registro de cliente.
POST /v1/customers com JSON como { \"email\": \"[email protected]\", \"name\": \"Pat\" }. O servidor sempre retorna a mesma forma de erro e sempre inclui um request_id.
O email está ausente ou mal formatado. O cliente pode destacar o campo.
{
\"request_id\": \"req_01HV9N2K6Q7A3W1J9K8B\",
\"error\": {
\"code\": \"VALIDATION_FAILED\",
\"message\": \"Some fields need attention.\",
\"details\": {
\"fields\": {
\"email\": \"must be a valid email address\"
}
}
}
}
O email já existe. O cliente pode sugerir entrar ou escolher outro email.
{
\"request_id\": \"req_01HV9N3C2D0F0M3Q7Z9R\",
\"error\": {
\"code\": \"ALREADY_EXISTS\",
\"message\": \"A customer with this email already exists.\"
}
}
Uma dependência está caída. O cliente pode tentar novamente com backoff e mostrar uma mensagem tranquila.
{
\"request_id\": \"req_01HV9N3X8P2J7T4N6C1D\",
\"error\": {
\"code\": \"TEMPORARILY_UNAVAILABLE\",
\"message\": \"We could not save your request right now. Please try again.\"
}
}
Com um contrato único, o cliente reage consistentemente:
details.fieldsrequest_id como ID de suportePara suporte, o mesmo request_id é o caminho mais rápido para achar a causa real nos logs internos, sem expor stack traces ou erros de banco.
A maneira mais rápida de irritar clientes de API é fazer com que eles adivinhem. Se um endpoint retorna { \"error\": \"...\" } e outro retorna { \"message\": \"...\" }, todo cliente vira um amontoado de casos especiais, e bugs ficam escondidos por semanas.
Alguns erros aparecem repetidamente:
code estável que os clientes possam usar.request_id apenas em falhas, de modo que você não consiga correlacionar um relato do usuário com a chamada bem-sucedida que iniciou um problema posterior.Vazar internos é a armadilha mais fácil. Um handler retorna err.Error() porque é conveniente, então um nome de constraint ou uma mensagem de terceiro vai parar em respostas de produção. Mantenha a mensagem para o cliente segura e curta, e coloque a causa detalhada nos logs.
Confiar apenas em texto é outra bomba-relógio. Se o cliente precisa parsear sentenças em inglês como “email already exists”, você não pode mudar a redação sem quebrar a lógica. Códigos de erro estáveis permitem ajustar mensagens, traduzi-las e manter o comportamento consistente.
Considere códigos de erro parte do seu contrato público. Se precisar mudar um, adicione um código novo e mantenha o antigo funcionando por um tempo, mesmo que ambos mapeiem para o mesmo status HTTP.
Por fim, inclua o mesmo campo request_id em toda resposta, sucesso ou falha. Quando um usuário diz “funcionou, depois quebrou”, esse único ID muitas vezes salva horas de adivinhação.
Antes do release, faça uma checagem rápida de consistência:
error.code, error.message, request_id).VALIDATION_FAILED, NOT_FOUND, CONFLICT, UNAUTHORIZED). Adicione testes para que handlers não possam retornar códigos desconhecidos por acidente.request_id e logue-o para cada requisição, incluindo panics e timeouts.Depois disso, verifique alguns endpoints manualmente. Gere um erro de validação, um registro ausente e uma falha inesperada. Se respostas parecerem diferentes entre endpoints (campos mudam, status driftam, mensagens vazam), corrija o pipeline compartilhado antes de adicionar mais funcionalidades.
Uma regra prática: se uma mensagem ajudaria um atacante ou confundiria um usuário normal, ela pertence aos logs, não à resposta.
Escreva o contrato de erro que você quer que todo endpoint siga, mesmo que sua API já esteja em produção. Um contrato compartilhado (status, código de erro estável, mensagem segura e request_id) é a maneira mais rápida de tornar erros previsíveis para clientes.
Depois migre gradualmente. Mantenha seus handlers existentes, mas roteie suas falhas por um mapeador que transforme erros internos na forma pública desejada. Isso melhora a consistência sem um grande rewrite arriscado e evita que novos endpoints inventem novos formatos.
Mantenha um catálogo pequeno de códigos de erro e trate-o como parte da sua API. Quando alguém quiser adicionar um código novo, faça uma revisão rápida: é realmente novo, o nome está claro e mapeia para o status HTTP correto?
Adicione alguns testes que capturem derivações:
request_id.error.code está presente e vem do catálogo.error.message permanece seguro e nunca inclui detalhes internos.Se você está construindo um backend Go do zero, ajuda travar o contrato cedo. Por exemplo, Koder.ai (koder.ai) inclui um modo de planejamento onde você pode definir convenções como esquema de erro e catálogo de códigos desde o início, e então manter handlers alinhados conforme a API cresce.
Use one JSON shape for every error response, across every endpoint. A practical default is a top-level request_id plus an error object with code, message, and optional details so clients can reliably parse and react.
Return error.message as a short, user-safe sentence and keep the real cause in server logs. Don’t return raw database errors, stack traces, internal hostnames, or dependency messages, even if it feels helpful during development.
Use a stable error.code for machine logic and let the HTTP status describe the broad category. Clients should branch on error.code (like ALREADY_EXISTS) and treat the status as guidance (like 409 meaning a state conflict).
Use 400 when the request can’t be reliably parsed or interpreted (malformed JSON, wrong types). Use 422 when the request is well-formed but fails business rules (invalid email format, password too short).
Use 409 when the input is valid but can’t be applied because it conflicts with current state (email already taken, version mismatch). Use 422 for field-level validation where changing the value fixes it without needing a different server state.
Create a small set of typed errors (validation, not found, conflict, unauthorized, internal) and have handlers return them. Then use one shared translator to map those types to status codes and the standard JSON response shape.
Always return a request_id in every response, success or failure, and log it on every server log line. If a client reports an issue, that one ID should be enough to find the exact failure path in logs.
Return 200 only when the operation succeeded, and use 4xx/5xx for errors. Hiding errors behind 200 forces clients to parse body fields and creates inconsistent behavior across endpoints.
Default to no retry for 400, 401, 403, 404, 409, and 422 because retries won’t help without changes. Allow retry for 503, and sometimes 429 after waiting; if you support idempotency keys, retries become safer for POST on transient failures.
Lock the contract with a few “golden” tests that assert status, error.code, and presence of request_id. Add new error codes without changing old meanings, and only add optional fields so older clients keep working.