Esta pagina se ve mejor con JavaScript habilitado

Go errors

 ·  🎃 kr0m

En Go, no existe un mecanismo especial para vincular métodos directamente a los tipos de error. Los errores en Go son simplemente valores que cumplen con la interfaz Error, que tiene un solo método, Error() string. Por lo tanto, cualquier tipo que implemente este método puede utilizarse como un error en Go.

Este artículo es bastante extenso así que lo he organizado de la siguiente manera:


Si eres nuevo en el mundo de Go te recomiendo los siguientes artículos anteriores:


Introducción:

Lo primero que debemos tener en cuenta cuando tratamos con errores es que nunca deben ser ignorados, siempre se deben parsear y proceder en consecuencia.

Por convención en Go el valor de error retornado por una función debe ser siempre el último, por ejemplo:

func f1(arg int) (int, error) {
    if arg == 42 {
        return -1, errors.New("can't work with 42")
    }
    return arg + 3, nil
}

Podemos programar errores custom de forma muy sencilla tan solo debemos cumplir con la interfaz Error de Go que requiere un único método, Error() string.

type error interface {
    Error() string
}

Cuando hacemos uso de fmt.Println() para mostrar un mensaje de error lo que está haciendo es mostrarnos la salida del método Error() del objeto error.


Custom error:

La manera mas sencilla de crear errores personalizados es mediante el método New() del paquete errors , donde podemos ver que el método retorna una estructura de tipo errorString con el campo s conteniendo el texto del error y el método Error() asociado a dicha estructura que simplemente retorna el campo s de la estructura.

func New(text string) error {
    return &errorString{text}
}

type errorString struct {
    s string
}

func (e *errorString) Error() string {
    return e.s
}

Veamos un ejemplo:

package main

import (
    "errors"
    "fmt"
)

func f1(arg int) (int, error) {
    if arg == 42 {
        return -1, errors.New("can't work with 42")
    }
    return arg + 3, nil
}

func main() {
    for _, i := range []int{7, 42} {
        if r, e := f1(i); e != nil {
            fmt.Println("f1 failed:", e)
        } else {
            fmt.Println("f1 worked:", r)
        }
    }
}
f1 worked: 10
f1 failed: can't work with 42

Custom error2:

Otra forma de generar errores un poco mas elaborados en los que podemos introducir placeholders de variables es mediante fmt.Errorf().

package main

import (
    "errors"
    "fmt"
)

func f1(arg int) (int, error) {
    if arg == 42 {
        return -1, fmt.Errorf("can't work with: %v", arg)
    }
    return arg + 3, nil
}

func main() {
    for _, i := range []int{7, 42} {
        if r, e := f1(i); e != nil {
            fmt.Println("f1 failed:", e)
        } else {
            fmt.Println("f1 worked:", r)
        }
    }
}
f1 worked: 10
f1 failed: can't work with: 42

Custom error advanced:

Sabiendo que los errores deben cumplir la interfaz Error() string, podemos crear una estructura llamada argError por ejemplo con campos personalizados y vincularla a la función Error() mediante Go methods .

Acto seguido en la función f2() creamos un objeto de tipo argError pasándole como parámetros los valores esperados por la estructura.

type argError struct {
    arg  int
    prob string
}

func (a argError) Error() string {
    return fmt.Sprintf("%d - %s", a.arg, a.prob)
}

func f2(arg int) (int, error) {
    if arg == 42 {
        argumentError := argError {
            arg: arg, 
            prob: "can't work with it",
        }
        return -1, argumentError
    }
    return arg + 3, nil
}

Veamos el ejemplo completo:

package main

import (
    "errors"
    "fmt"
)

type argError struct {
    arg  int
    prob string
}

func (a argError) Error() string {
    return fmt.Sprintf("%d - %s", a.arg, a.prob)
}

func f1(arg int) (int, error) {
    if arg == 42 {
        return -1, errors.New("can't work with 42")
    }
    return arg + 3, nil
}

func f2(arg int) (int, error) {
    if arg == 42 {
        argumentError := argError {
            arg: arg, 
            prob: "can't work with it",
        }
        return -1, argumentError
    }
    return arg + 3, nil
}

func main() {
    for _, i := range []int{7, 42} {
        if r, e := f1(i); e != nil {
            fmt.Println("f1 failed:", e)
        } else {
            fmt.Println("f1 worked:", r)
        }
    }
    for _, i := range []int{7, 42} {
        if r, e := f2(i); e != nil {
            fmt.Println("f2 failed:", e)
        } else {
            fmt.Println("f2 worked:", r)
        }
    }
}

Ejecutamos el código:

f1 worked: 10
f1 failed: can't work with 42
f2 worked: 10
f2 failed: 42 - can't work with it

Como podemos ver fmt.Println(“f2 failed:”, e) está mostrando la salida del método Error() del objeto de tipo argError.

func (a argError) Error() string {
    return fmt.Sprintf("%d - %s", a.arg, a.prob)
}
f2 failed: 42 - can't work with it

Assertion

Cuando un tipo de datos cumple una interfaz este pierde todos los campos y métodos que no se requieran en dicha interfaz, como la interfaz Error de Go tan solo requiere de un método Error() string, este método es a todo lo que podemos acceder. Pero como ya explicamos anteriormente podemos recuperar dichos campos mediante el assertion de Go.

En nuestro caso sería del siguiente modo:

    _, e := f2(42)
    if fieldsStructure, ok := e.(argError); ok {
        fmt.Println(fieldsStructure.arg)
        fmt.Println(fieldsStructure.prob)
    }

Veamos el ejemplo completo:

package main

import (
    "errors"
    "fmt"
)

type argError struct {
    arg  int
    prob string
}

func (a argError) Error() string {
    return fmt.Sprintf("%d - %s", a.arg, a.prob)
}

func f1(arg int) (int, error) {
    if arg == 42 {
        return -1, errors.New("can't work with 42")
    }
    return arg + 3, nil
}

func f2(arg int) (int, error) {
    if arg == 42 {
        argumentError := argError {
            arg: arg, 
            prob: "can't work with it",
        }
        return -1, argumentError
    }
    return arg + 3, nil
}

func main() {
    for _, i := range []int{7, 42} {
        if r, e := f1(i); e != nil {
            fmt.Println("f1 failed:", e)
        } else {
            fmt.Println("f1 worked:", r)
        }
    }
    for _, i := range []int{7, 42} {
        if r, e := f2(i); e != nil {
            fmt.Println("f2 failed:", e)
        } else {
            fmt.Println("f2 worked:", r)
        }
    }
    _, e := f2(42)
    if e != nil {
        if fieldsStructure, ok := e.(argError); ok {
            fmt.Println(fieldsStructure.arg)
            fmt.Println(fieldsStructure.prob)
        } else {
            fmt.Println("Error: ", e)
        }
    }
}

Ejecutamos el código:

f1 worked: 10
f1 failed: can't work with 42
f2 worked: 10
f2 failed: 42 - can't work with it
42
can't work with it

Errors.As:

Hay otro modo de acceder a los campos del objeto argError sin tener que recurrir al assertion pero es preciso que el receiver parameter del método de la estructura reciba un puntero del objeto y no una copia, las funciones como f2() que retornan un objeto error de este tipo deben retornar un puntero hacia el objeto y finalmente en main() debemos crear una variable de tipo puntero-argError la cual utilizaremos como parámetro de entrada en la función errors.As(). Si errors.As() consigue convertir el error al tipo indicado, retornará True.

package main

import (
    "errors"
    "fmt"
)

type argError struct {
    arg  int
    prob string
}

//func (a argError) Error() string {
func (a *argError) Error() string {
    return fmt.Sprintf("%d - %s", a.arg, a.prob)
}

func f1(arg int) (int, error) {
    if arg == 42 {
        return -1, errors.New("can't work with 42")
    }
    return arg + 3, nil
}

func f2(arg int) (int, error) {
    if arg == 42 {
        argumentError := argError{
            arg:  arg,
            prob: "can't work with it",
        }
        //return -1, argumentError
        return -1, &argumentError
    }
    return arg + 3, nil
}

func main() {
    for _, i := range []int{7, 42} {
        if r, e := f1(i); e != nil {
            fmt.Println("f1 failed:", e)
        } else {
            fmt.Println("f1 worked:", r)
        }
    }
    for _, i := range []int{7, 42} {
        if r, e := f2(i); e != nil {
            fmt.Println("f2 failed:", e)
        } else {
            fmt.Println("f2 worked:", r)
        }
    }

    _, e := f2(42)
    if e != nil {
        //if fieldsStructure, ok := e.(argError); ok {
        var pErr *argError
        if errors.As(e, &pErr) {
            fmt.Println(pErr.arg)
            fmt.Println(pErr.prob)
        } else {
            fmt.Println("Error: ", e)
        }
    }
}
f1 worked: 10
f1 failed: can't work with 42
f2 worked: 10
f2 failed: 42 - can't work with it
42
can't work with it

Personalmente no le veo ninguna ventaja a utilizar el assertion directamente, además con el assertion no precisa crear punteros.


Panic/Recover functions:

La manera idiomática de gestionar las condiciones anormales en Go es utilizando los errores que cubren la gran mayoría de casos posibles. Es importante recordar que se deben utilizar errores y evitar los panic/recover en la medida de lo posible.

Solo debemos utilizar los panic/recover en casos muy puntuales en los que la ejecución del programa no pueda continuar, algunos ejemplos pueden ser:

  • Un servidor web que falla al realizar un bindeo a un puerto, en este caso es razonable utilizar un panic ya que no es posible hacer nada mas.
  • Un error en una función que se llame internamente, donde el error lo ha cometido el programador, por ejemplo llamando a la función con parámetros de entrada incorrectos.

Cuando ocurre un panic el proceso es el siguiente:

  • Se para de inmediato la ejecución del programa.
  • Se ejecutan defers pendientes en la función donde ocurrió el panic.
  • Se retorna de la función sin ejecutar el resto de líneas de código.
  • Se repiten los tres pasos anteriores en la función de orden superior.
  • Cuando llega a la función main() imprime el stack trace.

Veamos un simple ejemplo en el que se llama a la función fullName() con parámetros incorrectos.

package main

import (
    "fmt"
)

func fullName(firstName *string, lastName *string) {
    defer fmt.Println("Deferred call in fullName")
    if firstName == nil {
        panic("Runtime error: first name cannot be nil")
    }
    if lastName == nil {
        panic("Runtime error: last name cannot be nil")
    }
    fmt.Printf("%s %s\n", *firstName, *lastName)
    fmt.Println("Returned normally from fullName")
}

func main() {
    defer fmt.Println("Deferred call in main")
    firstName := "Elon"
    fullName(&firstName, nil)
    fmt.Println("Returned normally from main")
}
Deferred call in fullName
Deferred call in main
panic: Runtime error: last name cannot be nil

goroutine 1 [running]:
main.fullName(0x32b9f7c76ee0?, 0x32b9f5a005b8?)
	/home/kr0m/test/test.go:13 +0x17a
main.main()
	/home/kr0m/test/test.go:22 +0x85
exit status 2

En el stack trace podemos ver que el panic ocurrió en la línea 13, función fullName():

main.fullName(0x32b9f7c76ee0?, 0x32b9f5a005b8?)
	/home/kr0m/test/test.go:13 +0x17a

Y que esta fué llamada desde la línea 22, función main().

main.main()
	/home/kr0m/test/test.go:22 +0x85

El conjunto panic/recover puede ser considerado similar al concepto try-catch-finally de otros lenguajes de programación, para recuperarnos de un panic, debemos ejecutar la función recover() desde un defer.

El proceso de panic/recover es el siguiente:

  • Se para de inmediato la ejecución del programa.
  • Se ejecutan defers pendientes en la función donde ocurrió el panic.
  • Al ejecutar un recover() la situación de pánico es eliminada.
  • Se retorna de la función sin ejecutar el resto de líneas de código.
  • Se sigue con la ejecución en la función de nivel superior como si no hubiese ocurrido nada.
package main

import (
    "fmt"
)

func recoverFullName() {
    if r := recover(); r != nil {
        fmt.Println("Recovered from ", r)
    }
}

func fullName(firstName *string, lastName *string) {
    defer recoverFullName()
    if firstName == nil {
        panic("Runtime error: first name cannot be nil")
    }
    if lastName == nil {
        panic("Runtime error: last name cannot be nil")
    }
    fmt.Printf("%s %s\n", *firstName, *lastName)
    fmt.Println("Returned normally from fullName")
}

func main() {
    defer fmt.Println("Deferred call in main")
    firstName := "Elon"
    fullName(&firstName, nil)
    fmt.Println("Returned normally from main")
}
Recovered from  Runtime error: last name cannot be nil
Returned normally from main
Deferred call in main

También podemos obtener el stack trace desde un recovery().

package main

import (
    "fmt"
    "runtime/debug"
)

func recoverFullName() {
    if r := recover(); r != nil {
        fmt.Println("Recovered from ", r)
        debug.PrintStack()
    }
}

func fullName(firstName *string, lastName *string) {
    defer recoverFullName()
    if firstName == nil {
        panic("Runtime error: first name cannot be nil")
    }
    if lastName == nil {
        panic("Runtime error: last name cannot be nil")
    }
    fmt.Printf("%s %s\n", *firstName, *lastName)
    fmt.Println("Returned normally from fullName")
}

func main() {
    defer fmt.Println("Deferred call in main")
    firstName := "Elon"
    fullName(&firstName, nil)
    fmt.Println("Returned normally from main")
}
Recovered from  Runtime error: last name cannot be nil
goroutine 1 [running]:
runtime/debug.Stack()
	/usr/local/go121/src/runtime/debug/stack.go:24 +0x5e
runtime/debug.PrintStack()
	/usr/local/go121/src/runtime/debug/stack.go:16 +0x13
main.recoverFullName()
	/home/kr0m/test/test.go:11 +0x70
panic({0x483c00?, 0x4b3dc0?})
	/usr/local/go121/src/runtime/panic.go:914 +0x21f
main.fullName(0x1ee600676ee0?, 0x1ee5fe4005b8?)
	/home/kr0m/test/test.go:21 +0x130
main.main()
	/home/kr0m/test/test.go:30 +0x85
Returned normally from main
Deferred call in main

La única limitación que tiene recovery() es que no puede ser utilizado desde otras go routines. Aquí podemos ver un ejemplo en el que la función divide() entra en pánico al realizar un división por 0, pero como está ejecutándose en otra go routine no se ejecuta la función recovery() que estaba como defered en sum().

package main

import (
    "fmt"
)

func recovery() {
    if r := recover(); r != nil {
        fmt.Println("Recovered:", r)
    }
}

func sum(a int, b int) {
    defer recovery()
    fmt.Printf("%d + %d = %d\n", a, b, a+b)
    done := make(chan bool)
    go divide(a, b, done)
    <-done
}

func divide(a int, b int, done chan bool) {
    fmt.Printf("%d / %d = %d", a, b, a/b)
    done <- true
}

func main() {
    sum(5, 0)
    fmt.Println("Normally returned from main")
}
5 + 0 = 5
panic: runtime error: integer divide by zero

goroutine 18 [running]:
main.divide(0x5, 0x0, 0x0?)
	/home/kr0m/test/test.go:22 +0xf6
created by main.sum in goroutine 1
	/home/kr0m/test/test.go:17 +0x14d
exit status 2

Si modificamos la función divide() para también tenga un defer recovery(), ejecutaremos el recovery pero como sabemos, al hacer un recovery se solventa el estado de pánico pero se retorna directamente a la función superior sin ejecutar el resto de código de la función que provocó el panic.

En este caso se retorna a sum() sin previamente haber escrito en el canal done, lo que provoca un deadlock ya que sum() está bloqueada hasta que alguien escriba en ese canal.

package main

import (
    "fmt"
)

func recovery() {
    if r := recover(); r != nil {
        fmt.Println("Recovered:", r)
    }
}

func sum(a int, b int) {
    defer recovery()
    fmt.Printf("%d + %d = %d\n", a, b, a+b)
    done := make(chan bool)
    go divide(a, b, done)
    <-done
}

func divide(a int, b int, done chan bool) {
    defer recovery()
    fmt.Printf("%d / %d = %d", a, b, a/b)
    done <- true
}

func main() {
    sum(5, 0)
    fmt.Println("Normally returned from main")
}
5 + 0 = 5
Recovered: runtime error: integer divide by zero
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.sum(0x5, 0x0)
	/home/kr0m/test/test.go:18 +0x159
main.main()
	/home/kr0m/test/test.go:28 +0x1a
exit status 2
Si te ha gustado el artículo puedes invitarme a un RedBull aquí