Estratégias de tratamento de erro

TL;DR

As quatro notas anteriores deram as peças — error, sentinelas, wrapping, tipos customizados. Esta nota dá a disciplina de uso: trate um erro uma única vez (log OU return, nunca os dois — a temida log-and-return duplication), envelope com contexto enquanto sobe a pilha de chamadas (não no ponto onde nasceu), e escolha entre sentinel, typed e opaque error olhando pra uma pergunta só: o chamador precisa decidir algo com base neste erro, ou só precisa saber que algo deu errado? A decisão de “onde tratar” também tem endereço: nas bordas do sistema (handler HTTP, main, worker de fila) — nunca no meio da pilha de domínio.

O log poluído

Um serviço de pedidos, três camadas de chamada: handler chama service, service chama repository. O repository falha ao buscar um pedido no banco:

func (r *Repository) FindOrder(id string) (*Order, error) {
    row := r.db.QueryRow("SELECT * FROM orders WHERE id = ?", id)
    var o Order
    if err := row.Scan(&o.ID, &o.Total); err != nil {
        log.Printf("erro ao buscar pedido %s: %v", id, err) // trata #1
        return nil, err
    }
    return &o, nil
}
 
func (s *Service) GetOrder(id string) (*Order, error) {
    o, err := s.repo.FindOrder(id)
    if err != nil {
        log.Printf("erro no service ao buscar pedido: %v", err) // trata #2
        return nil, err
    }
    return o, nil
}
 
func (h *Handler) GetOrderHandler(w http.ResponseWriter, r *http.Request) {
    id := r.URL.Query().Get("id")
    o, err := h.service.GetOrder(id)
    if err != nil {
        log.Printf("erro no handler: %v", err) // trata #3
        http.Error(w, "erro interno", 500)
        return
    }
    json.NewEncoder(w).Encode(o)
}

Rode isso em produção e um único pedido inexistente vira três linhas de log — a mesma falha, contada três vezes, em três níveis de detalhe diferentes, sem que nenhuma delas sozinha conte a história inteira. Quem lê o log de manhã não sabe se são três incidentes ou um só. Some volume de tráfego real e esse padrão vira ruído puro: o log deixa de ser instrumento de diagnóstico e vira arquivo que ninguém mais lê de verdade.

O problema não é logar. É logar e propagar o mesmo erro — cada camada faz as duas coisas, quando deveria fazer só uma.

A regra: handle once

A formulação mais citada da comunidade Go vem de Dave Cheney, num post que virou referência obrigatória sobre o assunto: um erro deve ser tratado uma única vez. Tratar significa uma de duas coisas, nunca as duas ao mesmo tempo:

  • Logar — você decidiu que este é o fim da linha para este erro. Ele vira uma entrada de log e o programa segue (ou não).
  • Retornar (possivelmente envelopado) — você decidiu que quem chamou você está em posição melhor pra decidir o que fazer, então repassa a decisão pra cima.
flowchart TB
    A["erro nasce no repository"] --> B{"repository decide:\nlog ou return?"}
    B -->|"return + wrap"| C["service recebe o erro"]
    C --> D{"service decide:\nlog ou return?"}
    D -->|"return + wrap"| E["handler recebe o erro"]
    E --> F{"handler decide:\nlog ou return?"}
    F -->|"log (borda do sistema)"| G["1 linha de log,\ncontexto completo"]
    F -->|"return"| H["resposta HTTP\n(nota do galho 10)"]

    style B fill:#F5A623,color:#000
    style D fill:#F5A623,color:#000
    style F fill:#F5A623,color:#000
    style G fill:#4A90D9,color:#fff

Repare que cada camada intermediária (repository, service) só faz uma metade da escolha: return, nunca log. Só a última camada — a borda — decide logar. O resultado é uma única entrada de log por falha, e essa entrada carrega o contexto acumulado de toda a subida, porque cada return envelopou um pouco:

func (r *Repository) FindOrder(id string) (*Order, error) {
    row := r.db.QueryRow("SELECT * FROM orders WHERE id = ?", id)
    var o Order
    if err := row.Scan(&o.ID, &o.Total); err != nil {
        return nil, fmt.Errorf("buscar pedido %s no banco: %w", id, err)
    }
    return &o, nil
}
 
func (s *Service) GetOrder(id string) (*Order, error) {
    o, err := s.repo.FindOrder(id)
    if err != nil {
        return nil, fmt.Errorf("service.GetOrder: %w", err)
    }
    return o, nil
}
 
func (h *Handler) GetOrderHandler(w http.ResponseWriter, r *http.Request) {
    id := r.URL.Query().Get("id")
    o, err := h.service.GetOrder(id)
    if err != nil {
        log.Printf("GetOrderHandler falhou: %v", err) // ÚNICO log, contexto completo
        http.Error(w, "erro interno", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(o)
}

Uma linha no log: GetOrderHandler falhou: service.GetOrder: buscar pedido 42 no banco: sql: no rows in result set. A cadeia inteira, numa entrada só, produzida pelo %w que a nota 03 já explicou em detalhe. Cheney resume o princípio numa frase que vale decorar: “only handle an error once — handling means inspecting the error and making a decision”. Logar e retornar ao mesmo tempo não é “tratar duas vezes com cuidado extra” — é tratar mal, duas vezes.

"Handle once" não é "não envelope"

É tentador ler a regra como “só a última camada mexe no erro”. Errado: cada camada intermediária deve envelopar com %w antes de retornar — é assim que o contexto chega até a borda. O que cada camada intermediária não deve fazer é logar também. Envelopar é parte do “return”; não é uma terceira ação proibida.

Envelopar subindo, não descendo

A ordem importa: contexto se adiciona na subida, uma camada de cada vez, nunca tentando adivinhar lá embaixo o que vai interessar lá em cima. O repository não sabe (e não deveria saber) que existe um HTTP handler chamando ele através do service — então ele envelopa só com o que sabe: “estava buscando este pedido, neste banco”. O service acrescenta a própria camada de contexto: “estava executando GetOrder”. Cada fmt.Errorf("%w", ...) é uma etiqueta grudada no pacote conforme ele sobe, não uma tentativa de prever o destino final.

// repository sabe sobre banco de dados — não sobre HTTP nem sobre o domínio "pedido do cliente X"
return nil, fmt.Errorf("buscar pedido %s no banco: %w", id, err)
 
// service sabe sobre a operação de negócio — não sobre banco nem sobre HTTP
return nil, fmt.Errorf("GetOrder: %w", err)
 
// handler sabe sobre a requisição HTTP — decide o que expor ao cliente
// (mapear pra status code é assunto do galho 10)

Essa disciplina resolve um problema clássico de debugging: erro genérico demais pra saber onde a falha aconteceu. sql: no rows in result set sozinho não diz nada — pode ter vindo de qualquer uma das dezenas de queries do sistema. A cadeia completa (GetOrderHandler falhou: service.GetOrder: buscar pedido 42 no banco: sql: no rows in result set) localiza a falha com precisão cirúrgica, sem precisar de stack trace nem de debugger anexado.

Sentinel, typed, opaque: qual usar, e onde decidir

As três notas anteriores do galho já ensinaram como declarar cada estilo — sentinela (var ErrNotFound = errors.New(...)), typed error (type ValidationError struct{...}) e a ideia implícita de erro opaco (só a mensagem, sem identidade programática). O que falta é o critério de quando usar cada um, e essa escolha se resume a uma pergunta sobre o chamador:

O código que vai chamar esta função precisa decidir algo diferente dependendo de qual erro voltou?

flowchart TD
    Q["o chamador precisa\nramificar o comportamento\ncom base no erro?"] -->|"não — só precisa saber\nque falhou, com contexto"| Opaque["Opaque error\nfmt.Errorf com %w"]
    Q -->|"sim — precisa comparar\ncontra um valor conhecido"| Q2["o erro tem dados\nassociados (campo, valor)?"]
    Q2 -->|"não, só identidade"| Sentinel["Sentinel error\nerrors.Is(err, ErrX)"]
    Q2 -->|"sim, carrega dados"| Typed["Typed error\nerrors.As(err, &x)"]

    style Q fill:#F5A623,color:#000
    style Q2 fill:#F5A623,color:#000
    style Opaque fill:#4A90D9,color:#fff
    style Sentinel fill:#4A90D9,color:#fff
    style Typed fill:#4A90D9,color:#fff
  • Opaque — a esmagadora maioria dos erros no meio de uma pilha de chamadas. fmt.Errorf("ler config: %w", err) — o chamador não vai fazer nada diferente dependendo do conteúdo exato; só precisa saber que a operação falhou, com contexto suficiente pra logar ou reportar. É o caso default; use os outros dois só quando tiver motivo concreto.
  • Sentinel — quando existe um número pequeno e fixo de condições nomeáveis que o chamador precisa distinguir, sem dados extras associados. sql.ErrNoRows, io.EOF, o ErrEstoqueInsuficiente da nota 02: o chamador faz if errors.Is(err, ErrEstoqueInsuficiente) { ... } e ramifica.
  • Typed — quando o chamador precisa não só saber que tipo de erro aconteceu, mas também extrair dados dele. ValidationError{Field: "email", Msg: "formato inválido"} da nota 04: o chamador faz errors.As(err, &ve) e depois lê ve.Field pra, por exemplo, destacar o campo errado num formulário.

A regra prática de ouro, ecoando o que Rob Pike já apontava desde os primeiros dias de Go (retomado por Dave Cheney em “Don’t just check errors, handle them gracefully”): exportar erro como valor comparável (sentinel/typed) é compromisso de API. Uma vez que ErrNotFound está exportado, qualquer código externo pode passar a depender dele com errors.Is — e você não pode mais mudar a implementação interna sem quebrar contrato. Erro opaco não tem esse custo: você pode reescrever a mensagem, trocar a causa raiz, sem quebrar ninguém, porque ninguém deveria estar comparando contra o texto.

Comparar mensagem de erro com string é a armadilha mais comum de todas

if err.Error() == "not found" { ... } ou strings.Contains(err.Error(), "not found") parecem funcionar em teste local e quebram silenciosamente no primeiro fmt.Errorf que muda uma palavra da mensagem, ou no primeiro wrapping que a envolve num prefixo. Mensagem de erro é para humano ler, não para código comparar — esse é o motivo de existir sentinel e typed error em primeiro lugar. Se você se pegar comparando .Error() com string, é sinal de que falta um sentinel ou um typed error ali.

Onde decidir: a borda, não o meio

A pergunta “handle once: log ou return?” tem uma resposta padrão por camada, e ela segue a arquitetura em camadas que qualquer serviço Go de porte médio já tem:

CamadaDecisão típicaPor quê
Repository / infrareturn (envelopado)não sabe o que fazer com a falha — só sabe onde ela aconteceu
Service / domínioreturn (envelopado), às vezes converte pra sentinel/typedconhece as regras de negócio, mas não sabe se é HTTP, CLI ou worker de fila chamando
Handler HTTP / main / workerlog e decide a resposta finalé a borda — não existe mais ninguém acima pra delegar a decisão

Essa tabela é uma instância direta do princípio geral que Dave Cheney chama de tratar erros nas bordas do sistema: o meio da pilha só propaga com contexto; a borda é onde o erro vira uma ação observável — log estruturado, resposta HTTP, exit code, retry de mensagem de fila. Duas fronteiras deste galho valem nomear aqui, sem entrar no mérito: erros de cancelamento/timeout vindos de context (context.Canceled, context.DeadlineExceeded) têm regras próprias de tratamento — assunto do galho 9 — e a conversão de erro de domínio pra status HTTP e corpo de resposta é o assunto inteiro do galho 10. Aqui a régua é só “log ou return”, não “que status HTTP usar”.

log/slog (Go 1.21+) facilita "logar uma vez, com contexto estruturado"

A partir do Go 1.21, o pacote padrão log/slog permite anexar o erro como campo estruturado em vez de interpolar tudo numa string: slog.Error("GetOrderHandler falhou", "err", err, "order_id", id). Isso reforça a disciplina de handle-once: o log na borda vira um evento estruturado e pesquisável (por order_id, por tipo de erro) em vez de uma linha de texto livre — sem mudar nada do raciocínio sobre onde tratar.

Casos práticos

1. Convertendo erro opaco em sentinel só na fronteira que precisa decidir — o repository não sabe de regra de negócio, mas o service sabe que “não encontrado” merece tratamento especial:

var ErrOrderNotFound = errors.New("pedido não encontrado")
 
func (r *Repository) FindOrder(id string) (*Order, error) {
    row := r.db.QueryRow("SELECT * FROM orders WHERE id = ?", id)
    var o Order
    if err := row.Scan(&o.ID, &o.Total); err != nil {
        return nil, fmt.Errorf("buscar pedido %s: %w", id, err) // opaco, envelopado
    }
    return &o, nil
}
 
func (s *Service) GetOrder(id string) (*Order, error) {
    o, err := s.repo.FindOrder(id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, fmt.Errorf("%w: id %s", ErrOrderNotFound, id) // vira sentinel aqui
        }
        return nil, fmt.Errorf("GetOrder: %w", err)
    }
    return o, nil
}
 
func (h *Handler) GetOrderHandler(w http.ResponseWriter, r *http.Request) {
    id := r.URL.Query().Get("id")
    o, err := h.service.GetOrder(id)
    switch {
    case errors.Is(err, ErrOrderNotFound):
        http.Error(w, "pedido não encontrado", http.StatusNotFound)
    case err != nil:
        slog.Error("GetOrderHandler falhou", "err", err, "order_id", id)
        http.Error(w, "erro interno", http.StatusInternalServerError)
    default:
        json.NewEncoder(w).Encode(o)
    }
}

O service é o lugar certo pra essa conversão porque é ele quem conhece a semântica de domínio (“não encontrado” é esperado, não excepcional); o repository só conhece SQL, e o handler só decide o que fazer com a decisão já tomada.

2. Worker de fila — a borda também pode ser um loop, não só um handler HTTP:

func (w *Worker) processMessage(msg Message) {
    if err := w.handle(msg); err != nil {
        var ve *ValidationError
        switch {
        case errors.As(err, &ve):
            slog.Warn("mensagem inválida, descartando", "field", ve.Field, "msg_id", msg.ID)
            msg.Ack() // erro de dado do cliente: não adianta reprocessar
        default:
            slog.Error("falha ao processar mensagem, reenfileirando", "err", err, "msg_id", msg.ID)
            msg.Nack() // erro possivelmente transitório: tenta de novo
        }
        return
    }
    msg.Ack()
}

Aqui o errors.As faz o trabalho de decisão que justifica ter um typed error em primeiro lugar: ValidationError carrega Field, então o log fica específico sem precisar fazer parsing de string; e a ramificação (Ack vs Nack) é exatamente o tipo de decisão que só a borda deveria tomar.

Armadilhas comuns

if err != nil { log.Println(err); return err } é a armadilha mais comum do galho inteiro

Aparece em quase todo tutorial de Go copiado sem pensar, e é exatamente o padrão que “handle once” proíbe. Se você se pegar escrevendo log e return juntos numa função que não é a borda do sistema, pare e pergunte: “quem vai consumir este log?” Se a resposta é “ninguém, porque o erro vai ser logado de novo lá em cima”, apague o log.

Sentinel/typed exportado demais vira dívida de API

Exportar ErrX ou TipoDeErro de um pacote é prometer que o formato daquele erro é estável — igual a exportar um campo de struct. Antes de exportar, pergunte se algum chamador de fato precisa ramificar com base nele; se não, deixe opaco e sem exportar. É fácil promover erro interno pra sentinel exportado depois que aparece necessidade real; o caminho inverso (des-exportar algo que já tem consumidor externo) quebra contrato.

Logar dentro de um for que repassa erro de item em item

Processar uma lista e logar cada erro individualmente dentro do loop, e depois também retornar um erro agregado pro chamador, reproduz a mesma duplicação em miniatura — só que multiplicada pelo tamanho da lista. Ou o loop trata cada erro (loga e segue pro próximo item) ou acumula os erros e devolve pro chamador decidir — não as duas coisas.

Vindo de outras linguagens

LinguagemPadrão comumDiferença em Go
Javacatch (Exception e) { log.error(e); throw e; } — log-and-rethrow é quase reflexo, porque exceções carregam stack trace de graçaGo não tem stack trace automático; o contexto vem do %w explícito em cada camada — então log-and-return duplica conteúdo, não só esforço
Pythonexcept Exception as e: logger.exception(e); raise — mesmo padrão de log-and-reraise, popular em frameworks webmesma lógica de “handle once” se aplica, mas em Python o hábito de log-and-reraise é mais tolerado porque o traceback agregado ainda ajuda; em Go, sem traceback, a duplicação de log é puro ruído
Node.js / JSmiddleware de erro no Express loga uma vez na borda — é, coincidentemente, o padrão que esta nota recomendaé o cenário mais parecido: a convenção de “error middleware só na borda” do Express é quase um espelho do handle-once de Go, só que Go formaliza isso sem framework nenhum

Como explicar em inglês

The core discipline for Go error handling is handle once: an error should either be logged (you’ve decided this is the end of the line) or returned, possibly wrapped with %w for context — never both. Wrapping happens on the way up the call stack, one layer of context at a time; the repository adds “what I was doing,” the service adds its own layer, and only the boundary — an HTTP handler, main, a queue worker — actually logs, because it’s the last place with no one left to delegate the decision to. Choosing between a sentinel error, a typed error, and a plain opaque fmt.Errorf wrap comes down to one question: does the caller need to branch on this error? If not, keep it opaque — it’s cheaper to maintain and doesn’t become an API commitment. If yes, use errors.Is against a sentinel for simple identity checks, or errors.As against a typed error when the caller needs to extract data from the failure.

Termo PTTermo EN
tratar uma única vezhandle once
duplicação de log-e-returnlog-and-return duplication
envelopar na subidawrap on the way up
borda do sistemasystem boundary / edge
erro opacoopaque error
compromisso de APIAPI commitment
log estruturadostructured logging

O que vem a seguir

Esta nota deu a disciplina de onde e como tratar — mas ainda dentro do modelo de erro-como-valor que o Go abraça desde a primeira nota do galho. A próxima nota recua um passo e compara esse modelo, de frente, com o modelo de exceções de Java/Python/JS: por que Go recusou try/catch, o que se ganha (fluxo de controle explícito, sem “erro invisível” pulando frames) e o que se perde (verbosidade do if err != nil repetido) — a base conceitual para entender por que panic/recover (nota 05) existe mas é deliberadamente raro em código idiomático.

Veja também

Fontes