Padrões de tratamento de erros em APIs Go para respostas claras e consistentes
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.

Por que erros inconsistentes em APIs frustram clientes
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.
Um alvo simples: um contrato que todo endpoint segue
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:
- Posso corrigir meu input?
- Devo tentar de novo mais tarde?
- Preciso contatar o suporte?
Um conjunto prático de regras:
- Um esquema de resposta para todos os erros.
- Uma política de códigos de status (o mesmo tipo de erro sempre mapeia para o mesmo status HTTP).
- Uma política de mensagens seguras (o que os usuários podem ver vs o que fica interno).
- Um gancho de correlação (um request ID retornado para que o suporte encontre a falha).
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.
Defina um esquema de resposta de erro em que os clientes possam confiar
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:
- Erros de validação:
details.fieldscomo um mapa de campo para mensagem. - Limites de taxa ou problemas temporários:
details.retry_after_seconds. - Orientação extra:
details.docs_hintcomo 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.
Erros tipados em Go: um modelo limpo para seus handlers
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:
- ValidationError (input inválido)
- NotFoundError (recurso ausente)
- ConflictError (constraint única, incompatibilidade de estado)
- UnauthorizedError (não autenticado ou sem permissão)
- InternalError (qualquer outra coisa)
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:
- Público (seguro para clientes): uma mensagem curta, um código estável e às vezes o nome de um campo para validação.
- Privado (apenas logs):
Errsubjacente, info de stack, erros SQL brutos, tokens, dados do usuário.
Essa separação permite ajudar clientes sem expor internos.
Mapear tipos de erro para códigos de status HTTP de forma consistente
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:
- 400 vs 422: use 400 quando você não consegue interpretar a requisição de forma confiável (JSON malformado, tipos errados). Use 422 quando você consegue parsear, mas os valores não são aceitáveis.
- 409 vs 422: use 422 para validação por campo (senha muito curta). Use 409 quando os dados são válidos mas não podem ser aplicados por causa do estado (email já usado, pedido já enviado, falha de lock otimista).
Orientação sobre retry importa:
- Geralmente seguro re-tentar: 503, e às vezes 429 (depois de esperar).
- Geralmente não é seguro re-tentar sem mudanças: 400, 401, 403, 404, 409, 422.
- Se a operação for idempotente (PUT com mesmo body, ou POST com chave de idempotência), retries ficam mais seguros mesmo após falhas transitórias.
Request IDs: a maneira mais rápida de debugar problemas do cliente
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.
Regras de geração e propagação
Use uma regra clara: se o cliente enviar um request ID, mantenha-o. Se não, crie um.
- Aceite um ID recebido de um único nome de header (escolha um e documente, por exemplo
X-Request-Id). - Se o header estiver ausente ou vazio, gere um novo ID na borda (middleware) e anexe ao contexto da requisição.
- Nunca troque o ID no meio da requisição. Passe-o para chamadas downstream (DB, outros serviços) via contexto ou headers.
Coloque o request ID em três lugares:
- Header de resposta (mesmo nome de header que você aceita)
- Corpo da resposta (como
request_idno seu esquema padrão) - Logs (como campo estruturado em cada linha de log)
Trabalho em lote e assíncrono
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.
Logging e métricas sem vazar internos
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:
- request_id
- user_id ou account_id (quando autenticado)
- código de erro público e status HTTP
- nome do handler/rota e método
- detalhe interno do erro (causa encadeada, erros de validação por campo, timeout upstream)
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.
Passo a passo: implemente um pipeline de erros consistente em Go
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.
O pipeline em 5 passos práticos
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:
- garante que toda requisição tenha um
request_id - recupere de panics
Um 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:
- Defina erros tipados com campos seguros e códigos estáveis.
- Traduza erros para status e JSON de resposta em um único lugar.
- Adicione middleware de request ID e recuperação de panic.
- Faça handlers retornarem erros, não respostas.
- Acrescente testes dourados (golden tests) para o tradutor e o wrapper.
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.
Exemplo: um endpoint, três falhas, respostas previsíveis
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.
1) Erro de validação (400)
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\"
}
}
}
}
2) Conflito (409)
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.\"
}
}
3) Falha transitória (503)
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:
- 400: marque campos usando
details.fields - 409: guie o usuário para um próximo passo seguro
- 503: solicite retry e mostre o
request_idcomo ID de suporte
Para 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.
Armadilhas comuns que pioram o tratamento de erros
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:
- Retornar HTTP 200 com um erro no corpo, ou alternar entre múltiplos esquemas de erro entre endpoints.
- Expor internos na mensagem do usuário, como erros SQL, stack traces, IPs, nomes de hosts de dependência ou caminhos de arquivo.
- Usar texto humano como único identificador, em vez de um
codeestável que os clientes possam usar. - Mudar códigos de erro casualmente (ou reutilizar o mesmo código para problemas diferentes) e quebrar clientes escritos para o comportamento antigo.
- Incluir
request_idapenas 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.
Checklist rápido antes de enviar
Antes do release, faça uma checagem rápida de consistência:
- Uma forma de erro em todo lugar. Todo endpoint retorna os mesmos campos JSON (por exemplo:
error.code,error.message,request_id). - Códigos de erro estáveis com cobertura. Mantenha códigos curtos e óbvios (
VALIDATION_FAILED,NOT_FOUND,CONFLICT,UNAUTHORIZED). Adicione testes para que handlers não possam retornar códigos desconhecidos por acidente. - Um conjunto de regras de mapeamento de status. Decida como cada tipo de erro mapeia para um status HTTP, e aplique isso em um lugar compartilhado.
- Request ID em ambos os sentidos. Sempre retorne um
request_ide logue-o para cada requisição, incluindo panics e timeouts. - Mensagens seguras por padrão. Mensagens para usuários devem ser curtas, claras e acionáveis; nunca stack traces, erros SQL ou nomes de provedores.
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.
Próximos passos: padronize agora e mantenha consistência depois
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:
- Toda resposta de erro inclui
request_id. - Código de status combina com o tipo de erro (não com o texto do erro).
error.codeestá presente e vem do catálogo.error.messagepermanece seguro e nunca inclui detalhes internos.- Erros desconhecidos caem para 500 com uma mensagem genérica.
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.
Perguntas frequentes
What should a “consistent error response” look like?
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.
How do I avoid leaking internal details in API errors?
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.
Do I really need an error code if I already have HTTP status codes?
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).
When should I use HTTP 400 vs 422?
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).
When should I use HTTP 409 vs 422?
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.
How do typed errors in Go help keep responses consistent?
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.
How should I generate and return request IDs?
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.
Why is returning HTTP 200 with `{ "ok": false }` a bad idea?
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.
Which errors should clients retry, and which should they not?
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.
How do I prevent error responses from drifting as the API evolves?
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.