Skip to content

Instantly share code, notes, and snippets.

@ljtfreitas
Last active April 20, 2024 23:55
Show Gist options
  • Star 14 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ljtfreitas/dfbbbb7e03ecb7b02ac5f132d6cabb4b to your computer and use it in GitHub Desktop.
Save ljtfreitas/dfbbbb7e03ecb7b02ac5f132d6cabb4b to your computer and use it in GitHub Desktop.
Go error handling - O bom, o mau e o feio

Escrevendo código Go, frequentemente me pego gritando palavrões mentalmente (ou em alto e bom som) a respeito de diversos aspectos da linguagem. Pro bem ou pro mal, não dura mais do que um suspiro e ao fim e ao cabo apenas escrevo o código necessário para o problema em mãos.

Uma questão em particular está quase sempre presente nesses momentos: a maneira como Go lida com erros. Como sempre reclamo das mesmas coisas e de todo modo me resigno ao final, me ocorreu que poderia ser interessante sumarizar alguns pensamentos sobre o assunto.

O bom

Em Go, erros são tratados em duas categorias distintas: erros não recuperáveis e recuperáveis.

O primeiro caso é conceitualmente chamado de "panic" e representa uma situação em que a aplicação não pode continuar. Embora, na prática, seja possível implementar um mecanismo de recuperação mesmo nesse cenário, usualmente não é o que queremos ou mesmo precisamos fazer; com efeito, um panic representa uma situação em que o programa atingiu um estado inválido de tal tipo que a execução precisa ser interrompida. Existem exceções, naturalmente (daí o fato da própria linguagem permitir retomar a execução mesmo quando um panic ocorre), mas usualmente e para os propósitos desse texto, esse entendimento é o suficiente.

O segundo caso, mais usual, são erros recuperáveis que representam situações em que algum problema aconteceu mas a aplicação é capaz de determinar o que deve ser feito a seguir, seja implementado um caminho alternativo, adicionando informações contextuais ao problema, ou mesmo interrompendo o programa se necessário. A diferença fundamental é que o código é capaz de lidar com o fato de que um problema aconteceu e reagir de acordo.

Em Go, erros recuperáveis são tratados como valores do tipo error, cuja interface é:

type error interface {
    Error() string
}

Valores do tipo error são valores ordinários, como string, int, etc, e podem ser manipulados normalmente pelo código. Com efeito, essa é uma das características definidoras de como erros são modelados em Go: como valores.

Go não oferece blocos especiais, como try/catch ou begin/rescue, para segregar a execução "sem erros" da manipulação do erro. Erros são valores comuns que podem ser facilmente criados, manipulados, e retornados em funções quando necessário, para indicar a ocorrência de problemas.

Go permite que uma função retorne mais de um valor, e esse recurso em particular é usado para representar a situação de que uma função pode retornar um valor qualquer de sucesso, ou um erro.

// o retorno indica que a função pode retornar um string ou um erro
func DoSomething() (string, error) {...}

output, err := DoSomething()
if err != nil {
   // um erro ocorreu
}

A interface error também é simples de ser satisfeita, o que torna trivial a criação de tipos de erros customizados caso seja necessário.

type MyError struct {
  msg string // informações detalhadas sobre esse tipo de erro especifico; poderiamos ter mais campos contextuais aqui
}

func (e *MyError) Error() string { 
  return e.msg
}

(o código cliente provavelmente terá que testar se o erro retornado pela função é do tipo MyError, mas veremos mais a frente como podemos fazer isso)

Na minha opinião, lidar com erros como valores é benéfico, pelo simples fato de que, sim, um erro é um valor que deveria ser manipulado normalmente pelo código; um erro representa uma situação anormal dado o comportamento esperado, ou de "sucesso" do programa, mas ainda assim, é um comportamento passível de acontecer. E o programa deveria modelar isso de acordo.

Um exemplo comum em Go para mostrar o idiomismo acima são as funções que manipulam arquivos, como os.Open:

func Open(name string) (file *File, err error)

O retorno da função acima nos diz que podemos obter um File ou erro: uma variedade de problemas poderiam acontecer nessa situação: o arquivo pode não existir, talvez não tenhamos permissões para abri-lo, etc; situações que podem acontecer ao tentarmos abrir um arquivo. Se elas podem acontecer, por que nosso código não deveria representá-las, assim como representamos o sucesso retornando um File?

O mesmo raciocício se aplica às regras de negócio mais comuns de aplicações que normalmente escrevemos. Existe um caminho "de sucesso" e outras situações que podem acontecer, tratadas como "exceções". Essas situações fazem parte do comportamento do programa da mesma maneira que o caso de sucesso. O código deveria ser capaz de modelar essas situações de acordo, usando valores, do mesmo modo que fazemos com o caso de sucesso.

Outro aspecto positivo de lidar com erros como valores, no nível da função, é (na minha opinião) termos um maior nível de local reasoning.

func DoSomething() (string, error) {...}
func DoSomethingElse() (string, error) {...}

// erros são retornados por função;
// a leitura de um código Go torna claro qual chamada de função retornou o erro em particular, e como ele foi manipulado

a, err := DoSomething()
if err != nil {
}

b, err := DoSomethingElse()
if err != nil {
}

Comparemos a um exemplo, digamos, em Python:

try:
  a = DoSomething()
  b = DoSomethingElse()
except:
  print("ops") // qual função gerou o erro?

Erros como valores nos permitem manipular a ocorrência de erros como parte integral do programa, pois é o que eles são. Fazê-lo no nível da função também aprofunda o nosso conhecimento daquela operação em particular usando a própria assinatura da função, já que ela nos diz, explicitamente, o que representa sucesso e erro.

Na minha opinião, erros como valores são uma coisa boa. Embora Go não tenha inventado isso, méritos para a linguagem.

E essa é a única coisa boa que vejo da maneira como Go lida com erros 🙃.

O mau

Como destacado acima, Go lida com erros como valores. Não obstante, na minha opinião, Go modela muito mal essa questão.

Retomando o exemplo anterior:

// o retorno indica que a função pode retornar um string ou um erro
func DoSomething() (string, error) {...}

output, err := DoSomething()
if err != nil {
   // um erro ocorreu
}

A assinatura da função está dizendo que ela retorna um string ou um erro. Será mesmo? Pensemos sobre essa, digamos, "feature" da linguagem que nos permite retornar múltiplos valores.

Minha percepção de pessoas aprendendo Go (e um dos meus papéis no meu atual trabalho é ajudar nisso) é que esse é um dos recursos da linguagem que as pessoas mais gostam, e de fato é útil em muitas situações. Digamos uma função simples que retorna o nome de uma pessoa e a idade:

func Pessoa() (string, int) {
  return "Tiago", 38
}

Isso é muito mais conveniente do que criar uma nova estrutura para representar uma "Pessoa" (e muitas linguagens exigiriam isso). Esse é um exemplo simples mas, creio, ajuda a ilustrar o que me parece que muitas pessoas apreciam: retornar um conjunto de valores que tem relação entre si sem a necessidade de introduzir novos tipos, porque nem sempre queremos isso (e em Go voce também poderia fazer isso, se quisesse).

A questão aqui é: o que a função acima está dizendo ao compilador? Que ela retorna um string e um int. Em Go não existem tuplas; a função efetivamente retorna mútiplos valores (ao invés de um valor que contém múltiplos). E o que essa sintaxe está dizendo é que essa função vai produzir esse conjunto de valores. Não "uma string ou um int". Ambos.

Com isso em mente, de volta à nossa função:

func DoSomething() (string, error) {...}

A assinatura acima diz ao compilador que nossa função produz "um string e um error". Com efeito, o código abaixo é válido e compila perfeitamente:

func DoSomething() (string, error) {
   return "sucess", errors.New("ooops")
}

Mas não parece fazer sentido retornar sucesso e erro; é uma situação mutuamente exclusiva. Acontece que a assinatura acima simplesmente não modela esse conceito, e sim o oposto 🙃. E agora?

Agora, assim como várias outras coisas em Go, escrever o código adequado é deixado como uma espécie de "exercício para o programador".

A pessoa escrevendo o código que precisa lidar com o fato de querermos retornar sucesso ou erro, tanto dentro quanto fora da função. A implementação comum do exemplo acima seria algo parecido com isso:

func DoSomething() (string, error) {
   return "sucess", nil // sucesso + erro "vazio"
}

func DoSomething() (string, error) {
   return "", errors.New("ops") // sucesso "vazio" + erro
}

Em um sistema de tipos um pouco mais bem pensado, um código como os três exemplos acima não existiriam, ou mesmo seriam possíveis, simplesmente porque eles não fazem sentido. Nós não queremos "sucesso e erro", queremos sucesso ou erro e que o sistema de tipos da linguagem represente isso de acordo. Não é o caso do Go.

E do lado de fora da função? A meu ver, ainda pior:

func DoSomething() (string, error) {}

// como vimos, output e err podem estar preenchidos...e agora?
output, err := DoSomething()
if err != nil { // por convenção verificamos se a função retornou um erro
}

Como a linguagem não impede que a função retorne sucesso e erro ao mesmo tempo, novamente fica a cargo do programador lidar com a exclusão mútua no call site da função; se retornou erro, desprezamos o sucesso. É uma lógica simples. Mas muito fácil de errar, pois é baseado somente em uma convenção. A linguagem não nos ajuda nem fornece nenhuma estrutura pra lidarmos com o problema.

O código abaixo também é perfeitamente válido:

func DoSomething() (string, error) {}

// como vimos, output e err podem estar preenchidos...e agora?
output, err := DoSomething()
if output != "" { // verificando o sucesso antes do erro...:booom:
}

Claro que o leitor desse texto pode estar pensando que eu sou um péssimo programador Go porque "a convenção é verificar o erro primeiro!". É verdade. Mas é só uma convenção; o código acima, que poderia ser considerado "inválido", compila perfeitamente 🙃.

Certa vez eu li um artigo de uma pessoa muito conhecida na comunidade brasileira de Go e por quem tenho enorme respeito, afirmando que o que ele "gosta" no tratamento de erros em Go é que "é fácil para alguém perceber quando algo está errado", digamos, em um pull request. Pessoalmente, e respeitosamente, acho que esse é um péssimo argumento, pelo fato de que não, não é fácil.

"Mas o programador tem que prestar atenção!", como no exemplo acima. Talvez seja verdade. Mas as pessoas cometem erros. É exatamente por isso que os compiladores e sistemas de tipos de linguagens de programação evoluíram: para ajudar você. O compilador não deveria servir a linguagem, e sim ajudar você a escrever um código seguro. A linguagem deveria nos ajudar a modelar os problemas da maneira apropriada, fornecendo construtos e abstrações que nos ajudam a representar nossas preocupações em forma de código. E o compilador deveria nos ajudar a garantir que o que escrevemos está correto.

Go simplesmente jogou isso pela janela em nome de uma suposta "simplicidade"; o que ganhamos é uma linguagem com muito mais coisas pra prestar atenção, o que é o exato oposto de "simplicidade".

O feio

Aqui entro em um terreno composto somente de impressões e opiniões pessoais. Dito isso, para além dos problemas conceituais comentados acima, quando penso no efeito que a manipulação de erros tem no código Go, "feio" provavelmente é a palavra que me vem à mente.

Ainda a respeito de sucesso/erro como retorno de função, precisamos lidar com múltiplas possibilidades no call site da função mas Go não tem nenhum mecanismo sofisticado para manipular valores, como destructuring ou pattern matching. O que temos para operar sobre o retorno da função é...um if.

Alguém poderia dizer que é "simples", eu digo que é pobre. É muito fácil errar, como demonstrado acima (e se compararmos a maneira como outras linguagens implementam mecanismos parecidos, como Elixir usando ok/error tuples, a sintaxe Go se torna ainda mais bisonha, mas não vou comparar ambos por ser somente gosto pessoal).

Outro pequenino, ou nem tanto, problema é que, como Go não retorna uma tupla e sim múltiplos valores, é difícil compor chamadas de função que podem retornar erro. Somando tudo, caso precisemos encadear várias funções que retornar "alguma coisa/erro", terminamos com um código imperativo que, pro bem ou pro mal, é o código Go "idiomático".

Podemos viver com isso; mais difícil de lidar é o ruído causado pela sequência interminável de "tratamentos" de erros:

func DoSomething() (string, error) {}
func DoSomethingElse(s string) (string, error) {}
func DoWhatever(s string) (string, error) {}

a, err := DoSomething()
if err != {
  // ...
}

b, err := DoSomethingElse(a)
if err != {
  // ...
}

c, err := DoWhatever(b)
if err != {
  // ...
}

Manipulação de cenários de erro é importante, tanto quanto o cenário de sucesso; mas como, na minha opinião, a modelagem dessa questão em Go é inadequada, terminamos somente introduzindo uma verbosidade em excesso, em uma sequência de comandos, do que tratar adequadamente os erros. Tanto que o "tratamento de erro" comum em Go é:

func DoSomething() (string, error) {}

func DoSomethingElse(s string) (string, error) {
   a, err := DoSomething()
   if err != {
      return "", err // apenas propaga o erro; será isso "error handling"?
   }
}

De fato, é comum que queiramos propagar o erro porque frequentemente não temos como lidar com ele no nível local. Digamos que você está fazendo uma operação em um banco de dados e ocorreu um erro; o que sua função irá fazer? Provavelmente nada pois de fato não há o que ela fazer, então o erro será propagado programa acima até a camada adequada. Ocorre que em Go, a propagação de erro como demonstrada no exemplo acima será repetida em todas as chamadas de função (e, lembrando que, qualquer um desses lugares sempre pode manipular o erro de maneira incorreta 😉) porque não temos como adiar a manipulação do erro. E, frequentemente, é o que queremos e precisamos, porque nem sempre precisamos manipular o erro assim que ele ocorre; precisamos, sim, representá-lo da maneira adequada.

Esse é um problema interessante e presente mesmo em linguagens que modelam erros com tipos, digamos, mais bem pensados que em Go: se uma função das profundezas do programa retorna algo como Result<Sucess, Err>, provavelmente muitas funções acima terão que propagar esse retorno também; a vantagem aqui é que podemos desempacotar o erro somente no ponto necessário, ao contrário de Go, precisamos lidar com o erro a cada chamada de função. O que causa somente ruído e não torna a manipulação de erro mais robusta; só mais irritante.

Para finalizar as partes "feias" da propagação de erros, é comum que isso seja feito adicionando algum tipo de contexto ao erro original (digamos, uma mensagem mais específica ou dados que possam ajudar a diagnosticar o problema); o que queremos aqui é envelopar um erro dentro de outro.

Em Go, isso pode ser feito usando a função fmt.Errorf. Essa função retorna um error e permite usar os recursos de formatação de strings para criação da mensagem:

err := fmt.Errorf("User %s (id %d) not found", "Tiago", 1234)

Se a string de formatação conter um "%w", e a variável correspondente for um error, o erro retornado por Errorf encapsulará o erro original (type-safe string programming 🙈):

a, err := DoSomething()
if err != nil {
 return fmt.Errorf("An error happened: %w", err)
}

Para finalizar (mesmo), mais acima havia comentado sobre a criação de erros customizados; é uma prática comum comumente chamada de "sentinel errors", em que você define os erros conhecidos/esperados para depois ser capaz de compará-los e verificar se os erros que ocorreram são dos tipos específicos que definimos. Pois bem. Go fornece duas funções úteis relacionadas a esse assunto: errors.Is e errors.As.

errors.Is permite comparar um erro com um valor, de modo que podemos verificar se o erro é o desejado. errors.Is também é esperta o bastante para pesquisar toda a árvore de erros, para casos de erros aninhados como o do exemplo anterior.

errors.As permite não somente verificar se o erro é um valor especifico mas transformar o valor no tipo desejado. Essa função em particular eu considero estranhamente projetada, podendo quebrar em tempo de execução caso você tente transformar o erro original em um valor que não é compatível com a interface error (eu considero esse tipo de problema particularmente ruim em uma linguagem compilada como Go), além de usar um idiomismo em Go que pessoalmente não gosto (efeito colateral em ponteiros) mas nesse caso parece particularmente ruim:

type MyError struct {}

func (e MyError) Error() string { return "" }

a, err := DoSomething()
if err != nil {
  var myError *MyError
  if errors.As(err, &myError) {
    //myError disponivel e convertido
  }
  
  // caso o teste acima não passe...temos uma variavel myError nula e disponível pra ser usada :boom:
}

Conclusão

É isso. Obrigado 🙃.

@rafaelpontezup
Copy link

Muito bom @ljtfreitas, de verdade. 👏🏻👏🏻👏🏻 Obrigado por compartilhar tuas percepções sobre Go e, principalmente, sobre como você enxerga um bom modelo de error handling.

@alexrosabr
Copy link

Sobre o que você chama de "mau": se a sua função retorna sucesso e erro ao mesmo tempo, ela é que está mal feita.

@thiagochfc
Copy link

thiagochfc commented Feb 1, 2024

Sobre o que você chama de "mau": se a sua função retorna sucesso e erro ao mesmo tempo, ela é que está mal feita.

Realmente o que ele quis dizer, a função está mal feita mas o compilador da linguagem não impede que isso aconteça e isso pode ser feito com muita facilidade e "desatenção".

@GSuaki
Copy link

GSuaki commented Feb 2, 2024

Gostei dos pontos 👏🏻 Na minha visão se tivesse a construção try do Zig já resolveria 90% dos problemas. Há muitos cenários onde não tem tratativa e só precisa propagar o erro, ocasionando a avalanche de:

   a, err := DoSomething()
   if err != {
      return "", err // apenas propaga o erro; será isso "error handling"?
   }

@igorcafe
Copy link

igorcafe commented Feb 2, 2024

É perfeitamente válido retornar um valor e um erro.
Um exemplo disso é o io.Reader, que mesmo falhando, retorna a quantidade de bytes que ele conseguiu ler antes de falhar e o erro.

@ljtfreitas
Copy link
Author

Obrigado pela leitura pessoal ❤️

@ljtfreitas
Copy link
Author

ljtfreitas commented Feb 2, 2024

É perfeitamente válido retornar um valor e um erro. Um exemplo disso é o io.Reader, que mesmo falhando, retorna a quantidade de bytes que ele conseguiu ler antes de falhar e o erro.

Sem dúvida é válido, tem até mesmo um exemplo no artigo que retorna as duas coisas também 🙃, o que me incomoda é o quão semanticamente válido é isso. Determinar essa situação que voce comentou depende puramente do programador entender a semântica dessa função (Read, no seu exemplo) e implementar o código de acordo; a maneira como Go modela isso, a meu ver, não ajuda.

Digamos a citada função:

type Reader interface {
	Read(p []byte)(n int, err error)
}

myReader := //cria um Reader...

n, err := myReader.Read([]byte{})

O "adequado" seria:

n, err := myReader.Read([]byte{})

if err != nil {
   // erro, mas conseguimos saber quantos bytes foram lidos através da variável n
}

Ou, claro, eu poderia fazer esse código aqui, potencialmente incorreto, sem problema algum 🙃:

n, err := myReader.Read([]byte{})

if n > 0 {
   // se conseguiu ler algum byte assume "sucesso" e segue a vida...
}

Então a meu ver retornar "sucesso" e "erro" juntos continua semanticamente incorreto; ambos são mutuamente exclusivos em um cenário como esse. Mesmo que quisessemos saber quantos bytes foram lidos em caso de problema (o que faz sentido), o próprio valor error poderia conter esse dado; a instância/valor do erro poderia encapsular dados que permitam entender o que aconteceu.

Essa função em particular, ao contrário, retorna "sucesso e erro" juntos, e desambiguar o que ocorreu é papel da pessoa escrevendo a chamada da função.

Mas, claro, Go é assim 🙃

@Fernando-hub527
Copy link

Ótimas reflexões !

@ArtusC
Copy link

ArtusC commented Feb 6, 2024

Hey Tiago, tudo bom?

Gostei muito da forma que você escreveu o texto, mesmo eu (com meu infimo conhecimento), não tendo/vendo problema em tratar dos erros como Go trata.

O que me incomoda são as "oportunidades perdidas", explico: essa possibilidade de sempre retornar um erro com um valor explícito deveria servir para que pudéssemos identificar o erro mais facilmente, ou seja, deveria ajudar na identificação de "quando, onde e por que" o erro aconteceu.

Pois bem, há casos em que essa desatenção (ou simplesmente preguiça) torna inútil a utilização desse mecanismo, por exemplo:

def DoSomethingEasy() (string, error) {
	// "tenho plena e absoluta certeza que essa função nunca retornará um erro"
	[...]
	return "", nil
}

a, err := DoSomethingEasy()
if err != nil {
 return fmt.Errorf("An error happened: %w", err)
}

// Quando o erro acontece, o famigerado LOG na stack de erros:
 `An error happened: ` 

Ou quando o erro é tão "mal escrito" que, da mesma forma, não é possível identificar o que está acontecendo. Exemplos disso são os LOGs dos erros de corrida (race conditions) ou dos erros de ponteiros nulos.

Enfim, algo que foi pensado em ajudar pode acabar sendo uma enorme dor de cabeça (the good, the evil, the ugly, and the headache 😁) !

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment