GO: Como tratar erros?

Por que eu deveria centralizar os erros da minha aplicação em um pacote?

Olá, leitores do CodeAspiras! Tudo bem por aí?

Estamos de volta com mais um artigo sobre Golang, dessa vez falando sobre tratativa de erros e explicando mais um conceito relevante pra quem está começando a trabalhar nessa linguagem.

Primeiramente, gostaria de destacar o fato de que muitas pessoas odeiam os fluxos de erro do Golang, porque ficam com a sensação de que somos obrigados a criar diversas operações de condição para tratativas de erro.

É comum ficar puto por isso aqui, oh:

_, err := algumacoisa()
if err != nil {
    return nil, err
}

Um GIF de uma cena do filme "The Smile Man", mostrando o ator Willem Dafoe com uma expressão que todos podem associar a uma crise absurda de ansiedade.

Esse é um padrão que se repete bastante em todo o fluxo do seu código. É praticamente certo dizer que, quanto mais funções você desenvolve, mais checagens de erro você precisará injetar.

O jeito errado

Ah, Kiko, eu burlei isso. Eu decidi lançar panic e tratar com recover().

GIF do fantoche de Kermit Muppet desesperado, em pânico.

Por favor, NÃO FAÇA ISSO. Você só deve lançar panic em cenários que deveriam ser impossíveis de acontecer. Além disso, assumir que todos os trechos de código que chamarem sua função vão estar preparados para tratar panic como fluxo de erros comuns, definitivamente, não é uma boa forma de manter a qualidade do seu código em dia.

Infelizmente, o certo é aprender a lidar com os erros. É até filosófico colocar a frase desse jeito, né? A ideia é que você se acostume com o fato de que, sim, seu código vai falhar. Algumas falhas serão esperadas na regra de negócio, outras não.

Por exemplo, erros de validação são sempre falhas esperadas, se não você nem validaria, sabe? Erros de conexão ao banco de dados também são esperados, afinal, toda e qualquer transmissão de dados está exposta à falhas temporárias. Isso não é motivo para gerar um panic. Falhas de parsing também são esperadas - isso é, inclusive, uma forma de validação de dados. No geral, é pouco provável que você precise declarar qualquer sentença explícita de panic.

Ainda assim, é uma boa prática ter tratativas de panic com recover() na camada mais "alta" da aplicação. Por exemplo, em caso de servidores web, você pode fazer com que todas as suas rotas usem um middleware de recuperação de panic. E é um middleware bem simples:


defer func() {
    panicError := recover()
    if panicError == nil {
        return
    }

    // trata o panicError, enviando algum log ou sei lá o quê, mas impedindo o servidor de parar de funcionar
    // também deve encerrar a conexão da requisição, mandando algum status do que aconteceu para quem a enviou
}()
// suponha que a variável "next" segue a implementação de http.HandlerFunc: https://pkg.go.dev/net/http#HandlerFunc
// w é http.ResponseWriter
// r é *http.Request
next.ServeHTTP(w, r)

Se acontecer um panic durante a execução do handler, o código acionado no defer irá impedir que o processo do servidor seja encerrado, garantindo que falhas inesperadas não matem o serviço.

Já no caso de comandos CLI, que você roda no terminal e só executa uma tarefa, não faz sentido colocar tratativa de panic. Deixa o problema estourar e veja o que aparece no terminal... A menos, é claro, que seu terminal esteja programado para fechar automaticamente. Aí sim, você pode fazer uma tratativa de recover() pedindo para o usuário digitar algo para continuar antes de encerrar o código.

Bob Esponja tentando apagar um incêndio embaixo d'água... assoprando o fogo.

O jeito certo

Voltando aos erros, o certo é sempre transferir os erros até a camada "mais alta", sempre. Claro, se sua intenção não for interromper o fluxo, você pode só enviar um log de erro e continuar o fluxo normalmente sem nenhum return. A ideia é que você jamais ignore um cenário de erro. E aí, quando esse bendito erro respingar no handler da requisição ou no fluxo principal da aplicação, você pode verificar qual erro que aconteceu e tomar uma decisão adequada para tal.

package main

import "errors"

func vaiDarRuim() error {
    return errors.New("deu ruim")
}

func vouTentar() error {
    // isso aqui é meramente ilustrativo
    // na prática, eu teria colocado um "return vaiDarRuim()" direto
    err := vaiDarRuim()
    if err != nil {
        return err
    }

    return nil
}

func main() {
    err := vouTentar()
    if err != nil {
        // deu ruim, faz alguma coisa, loga, sei lá, reaja
        return
    }

    // deu bom, uhu
}

Mas se você cria um erro durante o fluxo do código, como você faz para detectar qual foi o erro? Bem, uma forma é verificar o texto:

switch(err.Error()) {
case "deu ruim":
     // trata o cenário "deu ruim"
default:
     // trata um cenário imprevisto
}

Porém isso não é recomendado, pois nada impede que dois erros de contextos diferentes tenham exatamente o mesmo texto, correto? Então não faz sentido analisar a mensagem de erro em si, mas a instância.

E nesse caso, para comparar instâncias significa que você precisa ter isso centralizado em um lugar: um pacote de erros. Independente se você prefere definir structs distintas de erro, inicializar erros com errors.New() ou fmt.Errorf(), o fato é que seus erros precisam ficar em um pacote sem referências a outras partes do projeto. Isso é sério, se você referenciar qualquer outro pacote da aplicação, você pode gerar um problema de importação cíclica.

package errs

import "errors"

var (
    ErrDeuRuim = errors.New("deu ruim")
)

Com essa variável, agora poderíamos mudar a função vaiDarRuim para:

func vaiDarRuim() error {
    return errs.ErrDeuRuim
}

E a tratativa de erros para:

switch(err) {
case errs.ErrDeuRuim:
    // agora sim, deu ruim mesmo
default:
    //...
}

GIF do meme do John Travolta perdido, tendo como fundo uma foto do erro do Google Chrome sem conexão com internet.

Conclusão

Depois de passar um tempo implementando esse pacote em vários projetos, comecei a sentir uma maior facilidade em várias coisas. Por exemplo, se alguém pergunta "qual a lista de possíveis erros da aplicação?", eu consigo rapidamente checar tudo em um pacote só. Obviamente, o erro daqui não demonstra muita coisa, afinal, a frase era uma string fixa, então não é muito difícil de migrar casos como esse. Os erros mais complexos de se transferir para o pacote foram os que tinham alguma injeção de valor no texto. E aí, ao invés de ter uma variável fixa de erro, eu tive de fazer uma struct implementando a interface error. Logo, na hora de analisar se o erro era o mesmo dessa struct, eu tinha de fazer a validação por casting:

if errCast, castable := err.(errs.ErrViaStruct); castable {
    // é o erro da struct errs.ErrViaStruct{}
}

O pacote errors até provê algumas funções como errors.Is() e errors.As(), mas não sinto muita segurança em utilizá-las, pois cada pacote que importo trata seus erros de forma diferente e essas funções não funcionam para todas as maneiras de lidar com erros.

E o mais importante é apenas criar tratativas especiais, não necessariamente identificar todos os erros possíveis em um switch-case só. Então a grande maioria dos erros que preciso tratar são de variáveis fixas. Vale a pena a implementação.

Indicação de leitura


E é isso aí! Curtiu? Comenta e compartilha! Está sentindo falta de algum artigo de uma linguagem específica? É só pedir que, se eu souber, eu escrevo alguma coisa, rs. Quero ver, hein? Abraço!

Inté!