O tipo `error` e tratamento idiomático de falhas

O tipo `error` e tratamento idiomático de falhas

Erros em Go: a interface error, errors.New e fmt.Errorf, tipos próprios de erro, embrulho com %w e o %v que quebra a cadeia, errors.Is, errors.As e o AsType do Go 1.26, sentinelas, errors.Join, o log repetido em cada camada e o recover que não atravessa goroutines.
Golang

• • 15 min de leitura

A primeira coisa que surpreende programadores vindos de Python, Java ou C# ao estudar Go é a ausência de exceções. Não existe try, não existe catch, não existe throw. Em Go, erros são valores comuns — retornados por funções, armazenados em variáveis, passados como argumentos e inspecionados com if.

Essa escolha não é uma limitação. É uma decisão filosófica com consequências práticas muito positivas: o fluxo de erros se torna explícito, visível no código e auditável. Para usar o resultado de uma função, o programador precisa receber também o erro, e o descarte fica escrito no código, com _. A garantia tem um limite que vale conhecer desde o início: uma chamada usada como instrução, sem nada à esquerda — os.Remove(caminho) sozinho numa linha —, descarta o erro em silêncio, e nem o compilador nem o go vet reclamam. O linter errcheck, ativo por padrão no golangci-lint, existe para fechar essa brecha.

A interface error

O tipo error em Go é uma interface simples definida na biblioteca padrão:

type error interface {
    Error() string
}

Qualquer tipo que implemente o método Error() string é um error. Essa simplicidade é o que permite que erros sejam tão flexíveis — podem carregar qualquer informação adicional necessária além da mensagem.

Criando erros simples

O pacote errors da biblioteca padrão oferece a forma mais direta de criar um valor de erro:

package main

import (
    "errors"
    "fmt"
)

func dividir(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("divisão por zero")
    }
    return a / b, nil
}

func main() {
    resultado, err := dividir(10, 2)
    if err != nil {
        fmt.Println("Erro:", err)
        return
    }
    fmt.Printf("Resultado: %.2f\n", resultado) // 5.00

    _, err = dividir(5, 0)
    if err != nil {
        fmt.Println("Erro:", err) // Erro: divisão por zero
    }
}

O valor nil como erro significa ausência de falha — a operação foi bem-sucedida. A convenção é sempre retornar nil quando não há erro.

Formatando erros com fmt.Errorf

O pacote fmt oferece fmt.Errorf para criar erros com mensagens formatadas, semelhante ao fmt.Sprintf:

func buscarUsuario(id int) (*Usuario, error) {
    if id <= 0 {
        return nil, fmt.Errorf("id inválido: %d", id)
    }
    // ...
    return nil, fmt.Errorf("usuário %d não encontrado", id)
}

Erros personalizados com contexto

Quando um erro precisa carregar informações estruturadas além de uma mensagem textual, cria-se um tipo personalizado que implementa a interface error:

package main

import "fmt"

type ErroValidacao struct {
    Campo   string
    Valor   any
    Motivo  string
}

func (e *ErroValidacao) Error() string {
    return fmt.Sprintf("validação falhou no campo '%s' (valor: %v): %s",
        e.Campo, e.Valor, e.Motivo)
}

type ErroHTTP struct {
    Codigo  int
    Metodo  string
    URL     string
}

func (e *ErroHTTP) Error() string {
    return fmt.Sprintf("HTTP %d ao chamar %s %s", e.Codigo, e.Metodo, e.URL)
}

func validarIdade(idade int) error {
    if idade < 0 {
        return &ErroValidacao{
            Campo:  "idade",
            Valor:  idade,
            Motivo: "não pode ser negativa",
        }
    }
    if idade > 150 {
        return &ErroValidacao{
            Campo:  "idade",
            Valor:  idade,
            Motivo: "valor improvável",
        }
    }
    return nil
}

func main() {
    if err := validarIdade(-5); err != nil {
        fmt.Println(err)

        // Inspecionando o tipo concreto
        if e, ok := err.(*ErroValidacao); ok {
            fmt.Printf("Campo problemático: %s\n", e.Campo)
        }
    }
}

Wrapping: embrulhando erros com contexto

Uma prática essencial em Go é adicionar contexto ao propagar erros pela pilha de chamadas. Em vez de retornar o erro original diretamente, embrulha-se ele com informações sobre o que estava acontecendo:

func lerArquivo(caminho string) ([]byte, error) {
    dados, err := os.ReadFile(caminho)
    if err != nil {
        return nil, fmt.Errorf("lerArquivo: falha ao ler %s: %w", caminho, err)
    }
    return dados, nil
}

func carregarConfig(caminho string) (*Config, error) {
    dados, err := lerArquivo(caminho)
    if err != nil {
        return nil, fmt.Errorf("carregarConfig: %w", err)
    }
    // ...
}

O verbo %w — disponível desde Go 1.13 — embrulha o erro original, preservando-o para inspeção posterior. A diferença para %v é decisiva: fmt.Errorf("ctx: %v", err) produz a mesma mensagem, mas guarda só o texto, e errors.Is deixa de encontrar o erro original. Desde o Go 1.20, um mesmo fmt.Errorf aceita mais de um %w, e os dois erros embrulhados são encontrados por errors.Is. O resultado é uma cadeia de erros com contexto acumulado:

carregarConfig: lerArquivo: falha ao ler config.json: open config.json: no such file or directory

Cada camada adiciona seu contexto, formando uma trilha que facilita o diagnóstico sem ocultar a causa raiz.

errors.Is: verificando erros específicos

Com erros embrulhados, verificar err == ErrEspecifico não funciona — o erro está embrulhado em outros. A função errors.Is percorre toda a cadeia de wrapping:

package main

import (
    "errors"
    "fmt"
    "os"
)

func lerConfig(caminho string) error {
    _, err := os.ReadFile(caminho)
    if err != nil {
        return fmt.Errorf("lerConfig: %w", err)
    }
    return nil
}

func main() {
    err := lerConfig("inexistente.json")

    if errors.Is(err, os.ErrNotExist) {
        fmt.Println("Arquivo não encontrado — usando configuração padrão")
    } else if err != nil {
        fmt.Println("Erro inesperado:", err)
    }
}

errors.Is verifica se algum erro na cadeia é igual ao alvo — tanto por comparação direta quanto pelo método Is(error) bool se o tipo o implementar.

errors.As: extraindo o tipo concreto da cadeia

Enquanto errors.Is verifica identidade, errors.As extrai o primeiro erro na cadeia que corresponde a um tipo específico:

package main

import (
    "errors"
    "fmt"
)

type ErroConexao struct {
    Host string
    Porta int
}

func (e *ErroConexao) Error() string {
    return fmt.Sprintf("falha ao conectar em %s:%d", e.Host, e.Porta)
}

func conectar(host string, porta int) error {
    return fmt.Errorf("iniciarServico: %w",
        &ErroConexao{Host: host, Porta: porta})
}

func main() {
    err := conectar("localhost", 5432)

    var erroConn *ErroConexao
    if errors.As(err, &erroConn) {
        fmt.Printf("Problema de conexão detectado\n")
        fmt.Printf("Host: %s\n", erroConn.Host)
        fmt.Printf("Porta: %d\n", erroConn.Porta)
    }
}

errors.As percorre a cadeia de wrapping e preenche a variável alvo com o erro encontrado, já com o tipo correto — sem necessidade de type assertion manual. Desde o Go 1.26, a versão genérica errors.AsType dispensa a variável declarada antes: if e, ok := errors.AsType[*ErroConexao](err); ok { ... }.

Erros sentinela: valores de erro pré-definidos

Erros sentinela são variáveis de erro exportadas que representam condições conhecidas. Permitem que os chamadores verifiquem condições específicas com errors.Is:

package main

import (
    "errors"
    "fmt"
)

// Erros sentinela exportados
var (
    ErrNaoEncontrado   = errors.New("registro não encontrado")
    ErrAcessoNegado    = errors.New("acesso negado")
    ErrDadosInvalidos  = errors.New("dados inválidos")
)

type BancoDados struct {
    dados map[int]string
}

func (db *BancoDados) Buscar(id int, usuarioAdmin bool) (string, error) {
    if !usuarioAdmin {
        return "", fmt.Errorf("Buscar id=%d: %w", id, ErrAcessoNegado)
    }
    valor, existe := db.dados[id]
    if !existe {
        return "", fmt.Errorf("Buscar id=%d: %w", id, ErrNaoEncontrado)
    }
    return valor, nil
}

func main() {
    db := &BancoDados{dados: map[int]string{1: "Ricardo", 2: "Ana"}}

    _, err := db.Buscar(1, false)
    fmt.Println(errors.Is(err, ErrAcessoNegado))   // true
    fmt.Println(errors.Is(err, ErrNaoEncontrado))  // false

    _, err = db.Buscar(99, true)
    fmt.Println(errors.Is(err, ErrNaoEncontrado))  // true
    fmt.Println(err) // Buscar id=99: registro não encontrado
}

Múltiplos erros: errors.Join

Desde Go 1.20, errors.Join permite combinar múltiplos erros em um único valor, útil para validações que precisam reportar todos os problemas de uma vez:

package main

import (
    "errors"
    "fmt"
)

type Formulario struct {
    Nome  string
    Email string
    Idade int
}

func (f Formulario) Validar() error {
    var erros []error

    if f.Nome == "" {
        erros = append(erros, errors.New("nome é obrigatório"))
    }
    if f.Email == "" {
        erros = append(erros, errors.New("email é obrigatório"))
    }
    if f.Idade < 18 {
        erros = append(erros, fmt.Errorf("idade mínima é 18, recebido: %d", f.Idade))
    }

    return errors.Join(erros...)
}

func main() {
    f := Formulario{Nome: "", Email: "", Idade: 15}

    if err := f.Validar(); err != nil {
        fmt.Println("Erros encontrados:")
        fmt.Println(err)
    }
}

panic e recover: para situações excepcionais

Go possui panic e recover, mas seu uso é reservado para situações verdadeiramente excepcionais — erros de programação irrecuperáveis, como índice fora dos limites ou desreferência de ponteiro nil. Não devem ser usados como substitutos para o fluxo normal de tratamento de erros.

panic interrompe a execução da goroutine atual e começa a desenrolar a pilha. recover pode capturar o pânico dentro de uma função defer:

package main

import "fmt"

func operacaoArriscada() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Pânico recuperado:", r)
        }
    }()

    var s []int
    fmt.Println(s[0]) // índice fora dos limites — causa pânico
}

func main() {
    operacaoArriscada()
    fmt.Println("Execução continua após recover")
}

A regra prática é clara: use error para falhas esperadas e recuperáveis. Reserve panic para condições que indicam bugs no código — estado corrompido, invariantes violadas, uso incorreto de APIs internas.

Boas práticas consolidadas

Adicione contexto ao propagar erros. Cada camada da aplicação deve adicionar informação relevante com %w:

return fmt.Errorf("processarPedido id=%d: %w", id, err)

Trate erros no nível correto. Um erro deve ser tratado — logado, convertido ou respondido ao usuário — apenas uma vez, na camada que tem contexto suficiente para fazer isso.

Não ignore erros com _ em código de produção. O compilador permite descartar erros com _, mas isso deve ser exceção extremamente rara com comentário explicando o motivo.

Erros sentinela para condições conhecidas. Defina variáveis de erro exportadas quando chamadores precisam distinguir condições específicas.

Tipos de erro para dados estruturados. Crie tipos personalizados quando o erro precisa carregar informações além da mensagem.

Tratar erros em Go é, na maior parte do tempo, escrever três coisas: devolver o erro, acrescentar contexto com %w e, na borda, decidir o que ele significa com errors.Is e errors.As. O resto é detalhe que evita armadilhas — o %v que corta a cadeia, o == que não enxerga o erro embrulhado, o log repetido em cada camada, o recover que só vale na própria goroutine. O panic continua existindo, mas como sinal de defeito no programa, não como forma de avisar que algo esperado deu errado.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Uma camada de serviço faz return fmt.Errorf("buscando cliente %d: %v", id, err). A mensagem no log fica perfeita, mas o handler HTTP, que faz if errors.Is(err, ErrNaoEncontrado) para devolver 404, passou a devolver 500 para clientes inexistentes. O que mudou?

Ver resposta

✓ Resposta: O verbo. Com %v, o fmt.Errorf formata o erro como texto e cria um erro novo que guarda só essa string; o original se perde. Com %w, a mensagem é idêntica, mas o erro novo guarda uma referência ao original e implementa Unwrap, e é por esse método que errors.Is e errors.As percorrem a cadeia. Num teste, errors.Is(fmt.Errorf("ctx: %v", ErrA), ErrA) dá false, e com %w dá true. Como a mensagem não muda, o defeito passa despercebido no log e só aparece no comportamento, aqui transformando 404 em 500. A regra é usar %w sempre que o chamador puder precisar reconhecer a causa. O %v é a escolha deliberada para esconder a causa, quando um detalhe interno, como o erro específico do driver do banco, não deve virar parte do contrato do pacote.

Exercício 2

Para tratar arquivo inexistente, alguém escreve if err == fs.ErrNotExist depois de um os.Open. O arquivo não existe, e mesmo assim o if não entra. Por quê, se os.Open nem passou por nenhum fmt.Errorf?

Ver resposta

✓ Resposta: Porque o erro devolvido por os.Open já é um embrulho: um *fs.PathError, que guarda a operação (open), o caminho e o erro do sistema, no Linux o ENOENT. É desse tipo que vem a mensagem open nao-existe.json: no such file or directory. A comparação com == olha só o valor mais externo, que é o *PathError, e dá false; errors.Is(err, fs.ErrNotExist) desce pela cadeia, chega ao erro do sistema, que declara equivaler a ErrNotExist, e dá true. Num teste com o mesmo arquivo inexistente, as duas formas deram exatamente false e true. A lição é que == contra erro sentinela só é seguro quando a documentação garante que o valor volta sem embrulho, como io.EOF do Read; em todos os outros casos, a forma certa é errors.Is. Para obter o caminho que falhou, errors.As com um *fs.PathError, ou errors.AsType desde o Go 1.26, extrai o embrulho inteiro.

Exercício 3

A função Validar() do artigo devolve errors.Join(erros...). Um colega teme dois problemas: que ela devolva um erro "vazio", mas não nil, quando não há falhas; e que a mensagem quebre o formato de uma linha por evento do log. Os temores procedem?

Ver resposta

✓ Resposta: O primeiro não procede: errors.Join ignora os argumentos nil e devolve nil quando não sobra nenhum erro. errors.Join(nil, nil) == nil dá true, e o mesmo vale para uma fatia vazia, então o if err := f.Validar(); err != nil funciona como esperado. O segundo procede: a mensagem é a concatenação das mensagens com quebra de linha entre elas; no teste, "nome é obrigatório\nemail é obrigatório". Num log de texto, isso vira duas linhas que o agregador trata como dois eventos, e a segunda aparece sem data nem contexto. Há duas saídas. Uma é registrar o erro como campo de um log estruturado, em que a quebra de linha fica escapada dentro do valor. A outra, quando os erros individuais importam, como numa resposta de API que lista os campos inválidos, é percorrê-los: o erro devolvido por Join tem o método Unwrap() []error, e cada item pode ser formatado do jeito que o destino exige.

Exercício 4

Em um projeto, cada camada faz log.Printf("erro: %v", err) e depois return fmt.Errorf("...: %w", err). Numa falha de banco, o mesmo problema aparece cinco vezes no log, com prefixos diferentes. O que a seção de boas práticas do artigo diz sobre isso, e como reorganizar?

Ver resposta

✓ Resposta: Diz que um erro deve ser tratado apenas uma vez, e registrar no log é tratar. Cada camada que loga e também devolve está tratando e repassando ao mesmo tempo, e o resultado são cinco registros do mesmo incidente, que multiplicam o volume de log e confundem quem investiga, porque parecem cinco falhas diferentes. A reorganização é separar os papéis. As camadas intermediárias só acrescentam contexto com %w e devolvem, sem logar. A borda, que no caso de um servidor HTTP é o handler ou um middleware, recebe a cadeia completa, decide a resposta (usando errors.Is e errors.As para escolher entre 404, 409 ou 500) e loga uma vez só. Como o %w acumulou o contexto de cada camada, essa única linha, algo como processarPedido id=7: salvando itens: conexão recusada, conta a história inteira. A exceção são erros que uma camada decide engolir, como uma falha de cache que não impede a operação: esses são tratados ali mesmo, e o log naquele ponto é a forma de não perdê-los.

Exercício 5

Para "nunca derrubar o processo", um serviço registra um defer com recover no main. Mesmo assim, o processo cai com panic: assignment to entry in nil map vindo de um worker iniciado com go func(). Por que o recover do main não pegou, e onde ele deveria estar?

Ver resposta

✓ Resposta: Porque o recover só intercepta pânicos da mesma goroutine em que está a função adiada. O pânico desenrola a pilha da goroutine onde aconteceu, e a do worker não passa pelo main; quando ela termina sem ninguém recuperar, o runtime encerra o processo inteiro. Num teste com o recover no main e a escrita num map nil dentro de uma goroutine, a saída é o pânico com a pilha, código de saída 2, e nem a mensagem do recover nem a do fim do main aparecem. Para proteger os workers, o defer com recover precisa estar dentro da função de cada goroutine, de preferência num pequeno invólucro reutilizável que registre o pânico e a pilha, com debug.Stack(), e converta o problema em erro para quem coordena. É o que o net/http faz por conta própria para cada requisição. Vale a advertência do artigo: isso é rede de segurança para bugs, e o pânico recuperado precisa aparecer em alerta e ser corrigido, não virar rotina.

Comentários

Mais em Golang

Documentação com godoc e boas práticas de comentário
Documentação com godoc e boas práticas de comentário

Documentação em Go: comentários que começam pelo nome, cabeçalhos, listas e…

Operadores, expressões e conversão de tipo
Operadores, expressões e conversão de tipo

Operadores em Go: aritméticos, de comparação, lógicos com curto-circuito e bit…

Goroutines: concorrência leve e o modelo de execução do Go
Goroutines: concorrência leve e o modelo de execução do Go

Goroutines em Go: o custo comparado ao de threads, o scheduler M:N e a…