Software · Go

Go 101, todo en Go es un error, ¿ahora cómo los envolvemos? 🎁

Índice↑ Volver al inicio
↑

Llevaba mucho tiempo queriendo hablar de este tema, porque cuando comencé mi vida profesional en Golang realmente no sabía qué era y hacía algo bastante rudimentario. No fue hasta tiempo después, ya en mi trabajo actual, que mi jefe me dijo, “¿Sabes qué son los wrapping errors?”. Y yo, con toda la astucia, le dije que sí. Resulta que no tenía ni la más remota idea. Cuando le expliqué lo que según yo creía que era, y se lo demostré con mi manera muy rudimentaria, me dijo, “ok, no es así”. Y desde que empecé a trabajar con ellos, no tienen idea de lo dependiente que me he hecho de esta característica de Go y lo esencial que es. Porque sí, casi todo en Go es un error, y lo digo literal, casi todas las funciones retornan nuestro conocido y a veces amado, a veces odiado err :=.

Bueno, haciendo el cuento corto, wrapping error es envolver errores sin perder la información en el camino, para poder ver de dónde viene el error.

Yo al principio hacía esto con muchísima confianza.

return fmt.Errorf("algo falló: %v", err)

O si quería algo más sofisticado, construía una estructura que podía tener diferentes campos y uno dedicado como bandera o centinela de dónde podía ocurrir el error.

Mucho trabajo para una feature que traía Golang desde la versión 1.13. Si tan solo hubiera leído la documentación en ese momento… pero así uno aprende, jajaja.

Así que en este post vamos a ver todo el tema de wrapping, pero con una condición, todo lo vamos a demostrar con tests. Y sí, resulta que vamos a combinar dos de las mejores características que tiene Go, los errores y los tests. Nada de “créeme que funciona”. Cada idea va a tener un test que la prueba, y todo el código de este post compila y pasa con go test.

Todo el código está en un repo público, para que lo clones y lo corras, Rivaldito/go101.

La muñeca rusa de errores 🪆

Antes de tocar código, quiero que nos formemos una imagen de lo que realmente significa wrapping errors. Veamos este concepto con algo de cultura, pensemos los errores en Go con una imagen. ¿Conocen las matrioskas, esas muñecas rusas que tienen otra muñeca adentro, y otra adentro, y otra adentro, y así sucesivamente?

Pues un error envuelto es exactamente eso.

  • La muñeca de afuera es el error que tú ves al final, con un mensaje como por ejemplo service.GetUserByID id=42: store.FindUserByID id=42: not found.
  • Cada capa por la que pasa el error añade su pedacito de contexto, por ejemplo, “esto pasó en el servicio”, “esto pasó en el store”.
  • Y la muñequita chiquita del centro es la causa real, not found.

El truco de todo esto, y lo que quiero que se lleven de este post, es aprender dos cosas, meter una muñeca dentro de otra sin romperla, y buscar una muñeca concreta sin tener que abrir todas a mano.

Para los ejemplos vamos a usar una mini app con tres capas, con la idea de simular un servicio de código real y, por encima de todo, los tests.

Test  →  Service.GetUserByID  →  Store.FindUserByID  →  ErrNotFound

El Store busca usuarios en un mapa, el Service usa el store, y los tests comprueban qué es lo que llega arriba del todo.

Okay, pero ahora sí, lo que nos saltamos e ignoramos, ¿qué es un error en Go? 🤔

En Go no hay excepciones, ni try/catch como tal vez lo hay en Python o JavaScript. Un error es simplemente un valor que devuelves, como devolverías un número o un string. Y ese valor tiene que cumplir una interfaz súper “chiquita”.

type error interface {
    Error() string
}

O sea, cualquier cosa que tenga un método Error() que devuelva un string ya es un error. Así de simple. Por eso vas a ver tanto este patrón en Go.

u, err := store.FindUserByID(42)
if err != nil {
    // algo salió mal, hago algo con err
}

La forma más fácil de crear un error es con errors.New.

var ErrNotFound = errors.New("not found")

Y aquí viene lo realmente interesante, si ese error viaja hacia arriba por tres o cuatro funciones, cuando llega arriba solo dice not found. ¿Not found qué? ¿Dónde? ¿Qué id? Ni idea. 🤷‍♂️

Para eso existe el wrapping, cada función por la que pasa el error le pone una capita de contexto encima, pero sin destruir el error original.

%w vs %v, una letra que lo cambia todo 🎁

Aquí tenemos que ser muy cuidadosos. En Go utilizamos % seguido de una letra para imprimir algo. Muchas veces la letra que usas (o usabas) era %v (realmente no quiero explicar para qué es cada letra, seguramente en un próximo post). Bueno, desde Go 1.13, fmt.Errorf tiene un verbo especial, %w (de wrap, envolver). La diferencia con el %v de toda la vida es esta.

  • %v agarra el error, saca su texto y lo pega en el mensaje nuevo. El error original desaparece, lo que queda es solo un string.
  • %w hace lo mismo con el texto, pero además guarda el error original dentro del error nuevo. Se puede volver a sacar.

Y es aquí donde mucha gente pasa por alto, a simple vista los dos mensajes son IDÉNTICOS. Si imprimes el error, no ves ninguna diferencia. Por eso este tema es tan dejado de lado, o se le da tan poca importancia a niveles muy básicos en este lenguaje, y por eso necesitamos tests. 👀

Nuestro primer wrap

Para este primer ejemplo de wrap de errores vamos a usar el código que mencioné antes. Vamos a simular un servicio muy básico, por lo que nuestro código será el siguiente. Este es el store y el servicio que vamos a usar en todo el post.

package errwrap

import (
	"errors"
	"fmt"
)

// ErrNotFound is returned when a user is not found in the store.
var (
	ErrNotFound     = errors.New("not found")
	ErrUnauthorized = errors.New("unauthorized")
)

// User represents a user in the system.
type User struct {
	ID    int
	Email string
}

// Store represents a simple in-memory user store.
type Store struct {
	users map[int]User
}

// NewStore creates a new Store with some initial users.
func NewStore() *Store {
	return &Store{
		users: map[int]User{1: {ID: 1, Email: "[email protected]"}},
	}
}

// FindUserByID retrieves a user by their ID, returning an error if the user is not found.
func (s *Store) FindUserByID(id int) (User, error) {
	u, ok := s.users[id]
	if !ok {
		return User{}, fmt.Errorf("store.FindUserByID id=%d: %w", id, ErrNotFound)
	}
	return u, nil
}

// Service holds the business logic on top of the Store.
type Service struct {
	store *Store
}

// NewService creates a new Service with the given Store.
func NewService(store *Store) *Service {
	return &Service{store: store}
}

// GetUserByID retrieves a user's email by their ID, returning an error if the user is not found.
func (s *Service) GetUserByID(id int) (string, error) {
	user, err := s.store.FindUserByID(id)
	if err != nil {
		return "", fmt.Errorf("service.GetUserByID id=%d: %w", id, err)
	}
	return user.Email, nil
}

// GetUserByIDOpaque retrieves a user's email by their ID, returning an error if the user is not found.
func (s *Service) GetUserByIDOpaque(id int) (string, error) {
	user, err := s.store.FindUserByID(id)
	if err != nil {
		return "", fmt.Errorf("service.GetUserByIDOpaque id=%d: %v", id, err)
	}
	return user.Email, nil
}

Y para comprobar el código usaremos estos tests con intención de fallar, para obtener un error.

func TestWrapKeepChan(t *testing.T) {
	// Init the service
	svc := NewService(NewStore())
	// Get a wrong user
	_, err := svc.GetUserByID(333)
	// If we got nil, something is really wrong with our minimal code
	if err == nil {
		t.Fatal("expected error, got nil")
	}
	// We want this error
	want := "service.GetUserByID id=333: store.FindUserByID id=333: not found"
	if err.Error() != want {
		t.Fatalf("err.Error() = %q; want %q", err.Error(), want)
	}

	t.Logf("the full error: %v", err) //in this case we use %v, because t.Logf dont suport %w, and we want to see the error chain
	t.Logf("inside the error: %v", errors.Unwrap(err))
	t.Logf("inside the inside error: %v", errors.Unwrap(errors.Unwrap(err)))
}

func TestWrapOpaque(t *testing.T) {
	// Init the service
	svc := NewService(NewStore())
	// Get a wrong user
	_, err := svc.GetUserByIDOpaque(333)
	// If we got nil, something is really wrong with our minimal code
	if err == nil {
		t.Fatal("expected error, got nil")
	}
	// We want this error
	want := "service.GetUserByIDOpaque id=333: store.FindUserByID id=333: not found"
	if err.Error() != want {
		t.Fatalf("err.Error() = %q; want %q", err.Error(), want)
	}

	t.Logf("the full error: %v", err) //in this case we use %v, because t.Logf dont suport %w, and we want to see the error chain
	t.Logf("inside the error: %v", errors.Unwrap(err))
	t.Logf("inside the inside error: %v", errors.Unwrap(errors.Unwrap(err)))
}

Quiero que se fijen bien en el código. Vamos a analizar las dos funciones, GetUserByID y GetUserByIDOpaque, hacen exactamente lo mismo, sin embargo, en esta última para el error usamos %v en lugar de %w. ¿Qué implicaciones tendrá esto?

Bueno, corramos los tests.

Cuando ejecutamos TestWrapKeepChan obtenemos esta salida.

/home/apolo/go101/errWrap/errWrap_test.go:23: the full error: service.GetUserByID id=333: store.FindUserByID id=333: not found
/home/apolo/go101/errWrap/errWrap_test.go:24: inside the error: store.FindUserByID id=333: not found
/home/apolo/go101/errWrap/errWrap_test.go:25: inside the inside error: not found

Vemos que el t.Log pudo usar la función errors.Unwrap e ir desarmando el error como si de capas o muñecas rusas se tratara. Genial, vemos que no perdemos información.

Pero ¿qué pasará si ejecutamos el segundo test, TestWrapOpaque?

/home/apolo/go101/errWrap/errWrap_test.go:43: the full error: service.GetUserByIDOpaque id=333: store.FindUserByID id=333: not found
/home/apolo/go101/errWrap/errWrap_test.go:44: inside the error: <nil>
/home/apolo/go101/errWrap/errWrap_test.go:45: inside the inside error: <nil>

Okay, vemos que el primer t.Log tiene el error, y si vamos bajando vemos que tenemos… ¿nil? Sí, ¡nil! Perdimos la información, ya no podemos encontrar la raíz, está todo combinado. Y esto es muy importante que lo entendamos, ya que al usar %v no solo perdemos el Unwrap, también perdemos el uso de dos funciones muy importantes que tiene Golang para detectar si se contiene un error o algún elemento, errors.Is y errors.As. Las veremos más adelante.

Pero antes, un poquito de historia 📜

Antes de Go 1.13 (en el lejano 2019) esto no existía en la librería estándar. Lo normal era hacer fmt.Errorf("...: %v", err) y listo, perdías la causa. Quien quería algo mejor usaba librerías de terceros como github.com/pkg/errors, que tenía su propio errors.Wrap y errors.Cause, y al Go estar lleno de errores, el uso de esta librería fue tan popular que el equipo de Go terminó metiendo la idea en la librería estándar, y de ahí salieron %w, errors.Is, errors.As y errors.Unwrap.

¿Y bueno, pero qué rayos hace %w por dentro? 🔬

Esto me dio mucha curiosidad, para ser sincero, así que fui a mirar el código fuente de fmt (está en src/fmt/errors.go, por si quieren chismosear 👀). Resulta que fmt.Errorf no devuelve siempre lo mismo. Según cuántos %w le pongas, te devuelve un tipo distinto.

  • Ningún %w, un error normalito, igual que errors.New. Solo texto.
  • Un %w, un *fmt.wrapError.
  • Dos o más %w, un *fmt.wrapErrors (con s, ya llegamos a eso más adelante).

Y el wrapError es súper simple. Es básicamente esto.

type wrapError struct {
	msg string // el texto ya formateado
	err error  // la muñeca de adentro
}

func (e *wrapError) Error() string { return e.msg }
func (e *wrapError) Unwrap() error { return e.err }

O sea, guarda el mensaje ya armado para cuando lo imprimas, y guarda aparte el error original para que lo puedas volver a sacar con Unwrap. Esa es toda la magia. No hay nada más. 🪄

Y podemos comprobarlo con un test.

func TestErrorfConcreteTypes(t *testing.T) {
	cases := []struct {
		name string
		err  error
		want string
	}{
		{"without %w", fmt.Errorf("x: %v", ErrNotFound), "*errors.errorString"},
		{"with %w", fmt.Errorf("x: %w", ErrNotFound), "*fmt.wrapError"},
		{"with %w %w", fmt.Errorf("x: %w %w", ErrNotFound, ErrUnauthorized), "*fmt.wrapErrors"},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := reflect.TypeOf(c.err).String(); got != c.want {
				t.Errorf("type = %s; want %s", got, c.want)
			}
		})
	}
}

Por eso amo Go, algo tan útil es muy sencillo en el código.

errors.Is, ¿estás por ahí, ErrNotFound? 🔍

Antes de entrar con esta función muy útil de Go, primero vayamos con un poco de vocabulario. Un error centinela (sentinel error) es un error que declaras una sola vez como variable del paquete, para que cualquiera lo pueda comparar. Seguro que ya usaste alguno sin saber que tenía nombre, por ejemplo puedes estar familiarizado con estos, io.EOF, sql.ErrNoRows, os.ErrNotExist… todos son centinelas.

En nuestro ejemplo tenemos dos.

var (
	ErrNotFound     = errors.New("not found")
	ErrUnauthorized = errors.New("unauthorized")
)

Antes del wrapping, la forma de preguntar era con ==. Yo lo hacía así mucho tiempo atrás.

if err == ErrNotFound { ... }

El problema es que ahora err ya no es ErrNotFound, es una muñeca que contiene a ErrNotFound dos capas más adentro. Recuerda que este error pasó por muchas capas, y en un código real de producción, donde hay múltiples servicios o interfaces cliente, el error puede llegar a pasar por 4 o 5 capas o más. Si usamos ese acercamiento, tendremos que err es un valor distinto al que buscamos, así que == dice que no. Mentira no es, pero tampoco nos ayuda. 😅

Aquí es donde entra errors.Is. Lo que hace es ir abriendo la muñeca capa por capa y en cada una preguntar “¿eres tú el que busco?”. Si alguna dice que sí, devuelve true.

_, err := svc.GetUserByID(42)

err == ErrNotFound           // false: err es la capa de service
errors.Is(err, ErrNotFound)  // true: lo encuentra dos capas abajo

Lo bonito es que no te importa cuántas capas haya. Si mañana alguien mete un repository entre el servicio y el store, errors.Is sigue funcionando igual sin tocar nada.

Te invito a que leas la implementación del código de la función Is, es muy corta, unas pocas líneas de código y un bucle for.

¿Okay, y esto para qué sirve en la vida real?

El caso típico es un handler HTTP que tiene que decidir qué código devolver según qué falló.

switch {
case errors.Is(err, ErrNotFound):
	http.Error(w, "user not found", http.StatusNotFound)
case errors.Is(err, ErrUnauthorized):
	http.Error(w, "forbidden", http.StatusForbidden)
default:
	http.Error(w, "internal error", http.StatusInternalServerError)
}

El handler no tiene que saber nada de cómo funciona el store por dentro. Solo pregunta “¿esto fue un not found?” y actúa.

Casos frontera con nil

Siempre es buena idea tener un test para los bordes raros, porque es justo ahí donde uno asume cosas.

func TestNilIsNotWrapped(t *testing.T) {
	var err error
	if errors.Is(err, ErrNotFound) {
		t.Error("nil is not ErrNotFound")
	}
	if !errors.Is(nil, nil) {
		t.Error("errors.Is(nil, nil) should be true")
	}
}

Un error nil nunca es ningún centinela, pero errors.Is(nil, nil) sí es true. Raro pero lógico, nil == nil.

Ojo con una cosa aquí, en cuanto exportas un centinela y lo envuelves con %w, pasa a ser parte de la API pública de tu paquete. Habrá código por ahí haciendo errors.Is(err, tupaquete.ErrNotFound), así que no lo cambies o lo borres a la ligera.

errors.As, dame el error, pero con sus datos 🪪

Los centinelas están bastante bien si queremos preguntar “¿qué pasó?”, pero tienen un límite, son solo un texto fijo. No pueden decirte qué campo del formulario estaba mal, ni qué código devolvió la base de datos.

Pero Go nos da la facilidad de ir un poco más allá. Podemos crear nuestro propio tipo de error, un struct con los datos que nos interesan y un método Error() para cumplir la interfaz.

Veamos este ejemplo.

type ValidationError struct {
	Field  string
	Reason string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("validation failed on %q: %s", e.Field, e.Reason)
}

Y lo usamos en una validación y en el servicio.

func ValidateEmail(email string) error {
	if email == "" {
		return &ValidationError{Field: "email", Reason: "empty"}
	}
	return nil
}

func (s *Service) UpdateEmail(id int, email string) error {
	if err := ValidateEmail(email); err != nil {
		return fmt.Errorf("service.UpdateEmail id=%d: %w", id, err)
	}
	// Simulate updating the email in the store
	if _, err := s.store.FindUserByID(id); err != nil {
		return fmt.Errorf("service.UpdateEmail id=%d: %w", id, err)
	}
	return nil
}

Ahora, si UpdateEmail falla por validación, nos llega una muñeca con un *ValidationError adentro. ¿Cómo lo sacamos para leer el Field? Con errors.As.

La diferencia con Is en una frase.

  • errors.Is pregunta, ¿es este error concreto? (compara valores)
  • errors.As pregunta, ¿hay algún error de este tipo? Si sí, dámelo (compara tipos y te lo copia en una variable)
func TestErrorsAs(t *testing.T) {
	err := NewService(NewStore()).UpdateEmail(1, "")
	var ve *ValidationError
	t.Log(err)
	if !errors.As(err, &ve) {
		t.Fatalf("we expect *ValidationError in the chain; err = %v", err)
	}

	t.Log(ve.Field)
	t.Log(ve.Reason)
}

Y obtenemos esta salida.

=== RUN   TestErrorsAs
    /home/apolo/go101/errWrap/errWrap_test.go:76: service.UpdateEmail id=1: validation failed on "email": empty
    /home/apolo/go101/errWrap/errWrap_test.go:81: email
    /home/apolo/go101/errWrap/errWrap_test.go:82: empty
--- PASS: TestErrorsAs (0.00s)
PASS
ok      github.com/Rivaldito/go101/errWrap      0.005s

Aclaremos el As, ya que hay unos errores típicos

  • Pasar ve en vez de &ve. errors.As hace panic, porque el destino tiene que ser un puntero que no sea nil.
  • El molde equivocado. Si tu función devuelve &ValidationError{} (puntero) pero declaras var ve ValidationError (sin *), errors.As también hace panic. Como Error() está definido sobre el puntero, ValidationError a secas ni siquiera cumple la interfaz error. La buena noticia, go vet (que corre solo con go test) te avisa antes de llegar a ejecutar. El molde tiene que ser exactamente el tipo que devuelves.
  • Usar un type assertion err.(*ValidationError). Esto solo mira la capa de afuera. Con wrapping, la capa de afuera es la de fmt.Errorf, así que falla. errors.As mira todas.

El molde también puede ser una interfaz 🔌

Esto es algo que yo no sabía y me voló la cabeza un poquito 🤯, el molde de errors.As no tiene que ser un struct concreto. Puede ser una interfaz.

¿Y para qué sirve? Para preguntar por lo que el error sabe hacer, en vez de preguntar qué tipo exacto es. Imagina un cliente HTTP que tiene que decidir si reintenta una petición. No le importa si el fallo fue un timeout, una conexión cortada o un servidor saturado. Solo le importa una cosa, ¿se puede reintentar o no?

Así que definimos una interfaz con esa “capacidad”.

type Retryable interface {
	error
	Retryable() bool
}

Y cualquier error que tenga un método Retryable() la cumple automáticamente. Por ejemplo, un timeout.

type TimeoutError struct{ Op string }

func (e *TimeoutError) Error() string   { return e.Op + ": timeout" }
func (e *TimeoutError) Retryable() bool { return true }

Y ahora la parte bonita. La función que decide si reintentar ni siquiera conoce a TimeoutError.

func ShouldRetry(err error) bool {
	var r Retryable
	return errors.As(err, &r) && r.Retryable()
}

errors.As va abriendo las muñecas y en cada capa pregunta “¿tienes un método Retryable()?”. Si encuentra una, la mete en r y ya podemos llamarla. Mañana alguien crea un ConnResetError con su propio Retryable() y ShouldRetry funciona sin tocar ni una línea. 🙌

El test, en formato tabla como siempre.

func TestAsWithInterface(t *testing.T) {
	tests := []struct {
		name string
		err  error
		want bool
	}{
		{"wrapped timeout", fmt.Errorf("client.Get: %w", &TimeoutError{Op: "dial"}), true},
		{"not found", fmt.Errorf("client.Get: %w", ErrNotFound), false},
		{"nil", nil, false},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := ShouldRetry(tt.err); got != tt.want {
				t.Errorf("ShouldRetry(%v) = %v; want %v", tt.err, got, tt.want)
			}
		})
	}
}

Fíjense en el caso nil, errors.As con un error nil simplemente devuelve false, no explota. Así que ShouldRetry no necesita un if err == nil aparte.

Fabricando tus propias matrioskas

Hasta ahora todas las capas las ponía fmt.Errorf con %w. Pero tus propios tipos de error también pueden envolver otros errores. Solo necesitan un método.

Unwrap() error

Si un error tiene ese método, errors.Is y errors.As saben que adentro hay otra muñeca y la abren. Así de fácil.

Y hay un segundo método opcional, más interesante todavía.

Is(target error) bool

Con él tu error puede decir “oye, yo equivalgo a tal centinela”, aunque no lo tenga adentro. Es como si la muñeca dijera “no soy la que buscas, pero cuento como ella”. 🥸

Un ejemplo

Imagina un error de base de datos que guarda la consulta, un código numérico y la causa original. Queremos que cualquier error con código 404 se comporte como ErrNotFound, para que el handler HTTP de antes funcione sin cambiar nada.

type QueryError struct {
	Query string
	Code  int
	Err   error
}

func (e *QueryError) Error() string {
	return fmt.Sprintf("query %q (code %d): %v", e.Query, e.Code, e.Err)
}

// Unwrap lets errors.Is/As reach the cause.
func (e *QueryError) Unwrap() error { return e.Err }

// Is makes a 404 equivalent to ErrNotFound.
func (e *QueryError) Is(target error) bool {
	return target == ErrNotFound && e.Code == 404
}

Fíjense que en Error() uso %v y no pasa nada, porque ahí solo estoy armando el texto. Quien conecta la cadena es el método Unwrap.

El test cubre las tres formas de mirar este error, más el caso negativo.

func TestCustomIs(t *testing.T) {
	cause := errors.New("no rows")
	err := fmt.Errorf("repo: %w", &QueryError{Query: "SELECT 1", Code: 404, Err: cause})

	if !errors.Is(err, ErrNotFound) {
		t.Error("Code 404 should be equivalent to ErrNotFound")
	}
	if !errors.Is(err, cause) {
		t.Error("the original cause should still be reachable through Unwrap")
	}
	var qe *QueryError
	if !errors.As(err, &qe) || qe.Code != 404 {
		t.Errorf("errors.As failed or wrong Code: %v", qe)
	}

	other := &QueryError{Query: "SELECT 1", Code: 500, Err: cause}
	if errors.Is(other, ErrNotFound) {
		t.Error("Code 500 should not be equivalent to ErrNotFound")
	}
}
  • El primer if funciona gracias a nuestro método Is, no hay ningún ErrNotFound adentro, pero el 404 cuenta como uno.
  • El segundo funciona gracias a Unwrap, la causa no rows sigue ahí abajo.
  • El tercero es errors.As de toda la vida sacando el *QueryError para leer su código.
  • Y el último confirma que un 500 no se hace pasar por ErrNotFound. Este tipo de test negativo es el que más bugs atrapa, así que no lo saltes.

Otra forma de usar Is, comparar por tipo

En el ejemplo de arriba, Is comparaba contra un centinela. Pero también hay otro truco que se usa bastante, que tu Is diga que sí a cualquier error de su mismo tipo, sin importar los datos que lleve adentro.

¿Por qué haría falta esto? Porque cuando tus errores son punteros, == compara direcciones de memoria. Dos &RateLimitError{} creados en sitios distintos son dos punteros distintos, así que errors.Is nunca los ve como iguales, aunque tengan exactamente los mismos campos. 🫠

type RateLimitError struct{ RetryAfter int }

func (e *RateLimitError) Error() string {
	return fmt.Sprintf("rate limited, retry after %ds", e.RetryAfter)
}

// Any *RateLimitError counts as equal.
func (e *RateLimitError) Is(target error) bool {
	_, ok := target.(*RateLimitError)
	return ok
}

func TestIsByType(t *testing.T) {
	err := fmt.Errorf("api: %w", &RateLimitError{RetryAfter: 30})
	if !errors.Is(err, &RateLimitError{}) {
		t.Error("any *RateLimitError should match")
	}
	if errors.Is(err, ErrNotFound) {
		t.Error("should not be ErrNotFound")
	}
	// Without an Is method, two different pointers are never ==.
	a, b := &TimeoutError{Op: "x"}, &TimeoutError{Op: "x"}
	if errors.Is(a, b) {
		t.Error("different pointers should not match without Is")
	}
}

La última parte del test es la importante, TimeoutError no tiene método Is, y por eso dos timeouts idénticos no hacen match. Con RateLimitError sí, porque le dijimos cómo compararse.

Ahora, siendo honesto, si lo que quieres es saber “¿hay un error de este tipo?”, errors.As ya hace eso sin escribir nada extra. El Is por tipo tiene sentido cuando quieres que tu error se pueda usar en un switch con errors.Is junto a los centinelas, como en el handler HTTP de antes.

Cómo funciona errors.Is por dentro

Ahora que conocemos los dos métodos, podemos verlo como un conjunto completo. En cada capa de nuestra matrioska, errors.Is hace esto.

  1. ¿Este error es == al que busco? Si sí, true.
  2. ¿Tiene método Is(error) bool y devuelve true? Si sí, true.
  3. Si no, llama a Unwrap() y repite con la capa de adentro. Si ya no hay más capas, false.

errors.As hace lo mismo, pero con un método opcional As(any) bool. La verdad es que casi nunca hace falta implementarlo, con Unwrap sobra.

Ojo con esto, tu método Is no debe llamar a Unwrap ni recorrer la cadena por su cuenta. Eso ya lo hace errors.Is. Tu Is solo tiene que responder por su propia capa.

Varios errores a la vez, errors.Join 🧺

Hasta aquí cada error tenía una sola muñeca adentro. Pero hay casos donde fallan varias cosas a la vez y quieres devolverlas todas, no solo la primera.

El ejemplo clásico es validar un formulario. Imagina que rellenas un registro con el id mal y el email vacío. ¿Qué es más útil, que te diga “el id está mal”, lo arregles, vuelvas a enviar y entonces te diga “ah, y el email también”? ¿O que te diga las dos cosas de una? 😤

Desde Go 1.20 se puede. Un error puede envolver varios errores, y la muñeca rusa pasa a ser más bien una cesta con varias muñecas dentro. Hay dos formas de armarla.

  • errors.Join(err1, err2, ...)
  • fmt.Errorf con más de un %w en el mismo mensaje
func ValidateUser(u User) error {
	var errs []error
	if u.ID <= 0 {
		errs = append(errs, &ValidationError{Field: "id", Reason: "must be positive"})
	}
	if err := ValidateEmail(u.Email); err != nil {
		errs = append(errs, err)
	}
	return errors.Join(errs...) // nil if there are no errors
}

func DoubleWrap() error {
	return fmt.Errorf("both: %w and %w", ErrNotFound, ErrUnauthorized)
}

El patrón de ValidateUser me encanta, ya que vamos acumulando errores en un slice y al final se lo pasas todo a errors.Join. Si el slice está vacío, errors.Join devuelve nil, así que no necesitas ningún if len(errs) > 0.

Los tests

func TestJoin(t *testing.T) {
	err := ValidateUser(User{ID: 0, Email: ""})
	if err == nil {
		t.Fatal("expected error")
	}
	joined, ok := err.(interface{ Unwrap() []error })
	if !ok {
		t.Fatal("errors.Join should implement Unwrap() []error")
	}
	if n := len(joined.Unwrap()); n != 2 {
		t.Errorf("len = %d; want 2", n)
	}
	var ve *ValidationError
	if !errors.As(err, &ve) {
		t.Fatal("errors.As should find the first ValidationError")
	}
	if ve.Field != "id" {
		t.Errorf("first Field = %q; want id", ve.Field)
	}
	if ValidateUser(User{ID: 1, Email: "[email protected]"}) != nil {
		t.Error("errors.Join with no errors must return nil")
	}
}

func TestMultiplePercentW(t *testing.T) {
	err := DoubleWrap()
	if !errors.Is(err, ErrNotFound) || !errors.Is(err, ErrUnauthorized) {
		t.Errorf("both sentinels should be in the chain: %v", err)
	}
	if errors.Unwrap(err) != nil {
		t.Error("errors.Unwrap returns nil with several %w")
	}
}

Estos dos tests dejan claras varias cosas que no son nada obvias.

  1. Hay un Unwrap distinto. Los errores múltiples no tienen Unwrap() error, tienen Unwrap() []error (devuelve un slice). Por eso en el test hago un type assertion a interface{ Unwrap() []error } para poder contar cuántos errores hay adentro.
  2. errors.Unwrap devuelve nil. Esto confunde a mucha gente. errors.Unwrap solo sabe llamar a Unwrap() error (el de un solo error), así que con una cesta de errores no encuentra nada y devuelve nil. Para ver las ramas, usa el type assertion de arriba.
  3. Is y As sí recorren todo. errors.Is y errors.As entienden las dos formas de Unwrap y miran dentro de cada rama. Por eso DoubleWrap responde que sí tanto a ErrNotFound como a ErrUnauthorized.
  4. As devuelve el primero que encuentra. El recorrido es en profundidad y en orden, así que en TestJoin nos devuelve el error del campo id (el primero que metimos), no el del email.

Y un detallito práctico, el mensaje de errors.Join separa cada error con un salto de línea ("a\nb"). Si lo vas a meter en un log de una sola línea, tenlo en cuenta, porque te puede romper el formato.

Abriendo todas las muñecas a mano 🌳

Ya sabemos que errors.Is y errors.As recorren todo por nosotros. Pero para entender bien cómo lo hacen, me parece súper útil escribir ese recorrido a mano al menos una vez. Y de paso nos queda una función chula para depurar, le pasas un error y te imprime todas sus capas.

El truco está en que ahora hay dos tipos de Unwrap.

  • Unwrap() error, una sola muñeca adentro (lo de %w y nuestros tipos).
  • Unwrap() []error, varias muñecas adentro (lo de errors.Join y varios %w).

Así que la cadena ya no es una línea recta, es un árbol. Para recorrerlo uso una pila (stack), saco un error, guardo su texto, y meto en la pila lo que tenga adentro.

func Walk(err error) []string {
	var layers []string
	stack := []error{err}
	for len(stack) > 0 {
		e := stack[len(stack)-1]
		stack = stack[:len(stack)-1]
		if e == nil {
			continue
		}
		layers = append(layers, e.Error())
		switch u := e.(type) {
		case interface{ Unwrap() error }:
			stack = append(stack, u.Unwrap())
		case interface{ Unwrap() []error }:
			children := u.Unwrap()
			for i := len(children) - 1; i >= 0; i-- {
				stack = append(stack, children[i])
			}
		}
	}
	return layers
}

Un detallito, los hijos se meten en la pila al revés. Como una pila saca primero lo último que entró, si los meto al revés salen en el orden original. Así el recorrido es el mismo que hace errors.Is, en profundidad y de izquierda a derecha.

Dos tests, uno con una cadena recta y otro con un árbol.

func TestWalk(t *testing.T) {
	_, err := NewService(NewStore()).GetUserByID(42)
	got := Walk(err)
	want := []string{
		"service.GetUserByID id=42: store.FindUserByID id=42: not found",
		"store.FindUserByID id=42: not found",
		"not found",
	}
	if !reflect.DeepEqual(got, want) {
		t.Errorf("Walk = %q; want %q", got, want)
	}
}

func TestWalkTree(t *testing.T) {
	err := fmt.Errorf("handler: %w", errors.Join(ErrNotFound, ErrUnauthorized))
	got := Walk(err)
	want := []string{
		"handler: not found\nunauthorized",
		"not found\nunauthorized",
		"not found",
		"unauthorized",
	}
	if !reflect.DeepEqual(got, want) {
		t.Errorf("Walk = %q; want %q", got, want)
	}
}

En el segundo test se ve perfecto cómo es el árbol, arriba está el handler, abajo la cesta del Join, y de la cesta cuelgan las dos muñequitas. Y otra vez aparece el salto de línea \n que mete errors.Join entre mensajes.

Ojo, esto es para entender y para depurar. En código de verdad, para preguntar cosas sobre un error, sigue usando errors.Is y errors.As. Ya hacen este mismo recorrido, y además tienen en cuenta los métodos Is y As personalizados, que mi Walk ignora.

Testeando errores como se debe 🧪

Ya vimos un montón de tests sueltos. Ahora juntemos todo en patrones que puedas copiar tal cual en tus proyectos.

Pero primero, la regla de oro, y si te llevas una sola cosa de esta sección que sea esta, testea el contrato del error, no su texto.

¿Qué significa? Que en tus tests deberías preguntar con errors.Is, errors.As y mirar los campos del error, y no hacer cosas como strings.Contains(err.Error(), "not found"). El mensaje es para humanos y cambia todo el tiempo (alguien corrige una errata, añade un id, cambia el formato…). La cadena de errores, en cambio, es la API de verdad. Si comparas strings, tus tests se rompen por cosas que no importan y no se rompen por cosas que sí. 😬

Table-driven tests

En Go es súper común escribir los tests como una tabla, un slice de casos donde cada uno dice qué entrada le das y qué esperas. Luego un for los recorre todos. Para errores funciona genial, porque cada caso puede declarar qué tipo de fallo espera.

  • que no haya error,
  • que haya un centinela concreto (wantIs),
  • o que haya un tipo concreto con cierto campo (wantField).
func TestUpdateEmail(t *testing.T) {
	tests := []struct {
		name       string
		id         int
		email      string
		wantIs     error
		wantField  string
		wantNilErr bool
	}{
		{name: "ok", id: 1, email: "[email protected]", wantNilErr: true},
		{name: "empty email", id: 1, email: "", wantField: "email"},
		{name: "missing user", id: 99, email: "[email protected]", wantIs: ErrNotFound},
	}
	svc := NewService(NewStore())
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := svc.UpdateEmail(tt.id, tt.email)
			if tt.wantNilErr {
				if err != nil {
					t.Fatalf("unexpected err: %v", err)
				}
				return
			}
			if err == nil {
				t.Fatal("expected error")
			}
			if tt.wantIs != nil {
				assertErrorIs(t, err, tt.wantIs)
			}
			if tt.wantField != "" {
				var ve *ValidationError
				if !errors.As(err, &ve) {
					t.Fatalf("expected *ValidationError; err = %v", err)
				}
				if ve.Field != tt.wantField {
					t.Errorf("Field = %q; want %q", ve.Field, tt.wantField)
				}
			}
		})
	}
}

Lo bueno de esto es que añadir un caso nuevo es añadir una línea a la tabla. Y como cada caso corre dentro de t.Run, si uno falla ves exactamente cuál (TestUpdateEmail/empty_email) sin que los demás dejen de ejecutarse.

Un helper para no repetirte

Si te ves escribiendo el mismo bloque de errors.Is en veinte tests, sácalo a una función. La clave está en t.Helper(), sin eso, cuando el test falla, Go te dice que el fallo está en la línea del helper (que no te sirve de nada). Con eso, te señala la línea del test que lo llamó.

func assertErrorIs(t *testing.T, err, target error) {
	t.Helper()
	if !errors.Is(err, target) {
		t.Fatalf("errors.Is(%v, %v) = false", err, target)
	}
}

Si usas testify, require.ErrorIs y require.ErrorAs hacen exactamente esto.

Example tests, documentación que se ejecuta

Este es uno de mis trucos favoritos de Go. Una función que empieza por Example y termina con un comentario // Output: es un test, Go la ejecuta y compara lo que imprime con lo que pusiste en el comentario. Y además aparece en la documentación del paquete (go doc).

func ExampleService_GetUserByID() {
	_, err := NewService(NewStore()).GetUserByID(42)
	fmt.Println(err)
	fmt.Println(errors.Is(err, ErrNotFound))
	// Output:
	// service.GetUserByID id=42: store.FindUserByID id=42: not found
	// true
}

“Pero ¿no acabas de decir que no hay que comparar texto?” Sí, jaja. Este es el único sitio donde sí tiene sentido, porque aquí el objetivo es documentar cómo se ve el mensaje. Si alguien lo cambia, está bien que este test avise.

Cobertura

Así queda la ejecución completa de todo lo del post.

$ go test -v -cover ./...
--- PASS: TestWrapKeepChan
--- PASS: TestWrapOpaque
--- PASS: TestErrorfConcreteTypes (3 subtests)
--- PASS: TestErrorsAs
--- PASS: TestNilIsNotWrapped
--- PASS: TestAsWithInterface (3 subtests)
--- PASS: TestCustomIs
--- PASS: TestIsByType
--- PASS: TestJoin
--- PASS: TestMultiplePercentW
--- PASS: TestWalk
--- PASS: TestWalkTree
--- PASS: TestUpdateEmail (3 subtests)
--- PASS: ExampleService_GetUserByID
coverage: 94.4% of statements

Si quieres ver qué líneas se quedaron sin cubrir, esto te abre un HTML con el código pintado de verde y rojo.

go test -coverprofile=c.out && go tool cover -html=c.out

Casi siempre lo que sale en rojo son ramas de if err != nil. O sea, justo los caminos de error que nadie probó. Que es, irónicamente, donde suelen esconderse los bugs. 🐛

Ojo con esto, los errores más comunes ⚠️

Ya casi terminamos con este post, que es por mucho uno de los más largos que he hecho, pero antes quiero dejarte una lista de cosas que veo (y que he hecho 🙈) todo el tiempo, para finalizar bien.

Lo que sí hay que hacer ✅

  • Añade contexto que aporte. El contexto bueno dice dónde pasó y con qué datos, store.FindUserByID id=42. El contexto malo no dice nada, error, failed, something went wrong.
  • Minúsculas y sin punto final. Los mensajes se encadenan con : , así que Store.Find falló.: Not Found. queda horrible. Mejor store.Find: not found.
  • %w o %v es una decisión, no un descuido. Usa %w cuando quieras que quien te llama pueda inspeccionar la causa. Usa %v a propósito cuando quieras esconder un detalle interno. Por ejemplo, si tu paquete usa Postgres por debajo, a lo mejor no quieres que tus usuarios empiecen a depender de los errores del driver. Ahí %v te protege.
  • Centinelas para condiciones, tipos para datos. Si quien te llama solo necesita saber qué pasó (“no existe”, “sin permiso”), un centinela. Si necesita datos (qué campo, qué código), un tipo propio.
  • Una capa por frontera. Envuelve cuando el error cruza de una capa a otra de tu app (store → service → handler), no en cada línea.

Lo que NO hay que hacer ❌

  • Comparar strings. strings.Contains(err.Error(), "not found") funciona hoy y se rompe mañana en cuanto alguien toca el mensaje. Ya lo dije, pero lo repito porque es el más común. 😅
  • Usar == después de envolver. err == ErrNotFound deja de funcionar en cuanto hay una capa encima. Siempre errors.Is.
  • Loguear y devolver el mismo error. Si cada capa hace log.Println(err) y además return err, acabas con el mismo fallo repetido cuatro veces en los logs y no sabes cuál es cuál. O lo manejas (y logueas), o lo devuelves. Una de dos.
  • Contexto de relleno. fmt.Errorf("error: %w", err) no aporta nada, solo hace el mensaje más largo. Ya sabemos que es un error. 🥲
  • Envolver un nil. Este es traicionero. Si haces esto
return fmt.Errorf("algo: %w", err) // y err es nil

no te devuelve nil. Te devuelve un error de verdad, con el texto algo: %!w(<nil>). Tu función acaba de fallar sin que haya pasado nada malo. Por eso siempre va dentro de un if err != nil.

Resumiendo 🪆

Si tuviera que quedarme con algo de todo este post, sería la de la muñeca rusa.

  • %w mete una muñeca dentro de otra sin romperla. %v le saca una foto y tira la muñeca.
  • errors.Is busca una muñeca concreta abriendo todas las capas por ti.
  • errors.As busca una muñeca de cierto tipo y te la da para que puedas leer sus datos.
  • Unwrap e Is te dejan fabricar tus propias muñecas que funcionan igual que las de la librería estándar.
  • errors.Join convierte la muñeca en una cesta con varias muñecas adentro.

Y el hábito que de verdad te va a ahorrar dolores de cabeza, cada vez que devuelvas un error que alguien vaya a manejar, escribe un test con errors.Is o errors.As que lo demuestre. Si ese test no existe, tarde o temprano alguien va a cambiar un %w por un %v y nadie se va a enterar hasta producción. 💥

Sé que este post fue largo y con bastante código, pero creo que vale la pena tenerlo todo junto. Si quieres probarlo tú mismo, clona Rivaldito/go101, entra en errWrap y lanza go test -v -cover ./....

Ya en el próximo post seguimos con más Go. 👋

Para leer más 📚

La canción del post 🎵

Puedo decir que escribo posts de software y relacionados solo para hablar de música, jajaja.

ESTOY DEMASIADO FELIZ, una de mis bandas favoritas sacó un nuevo álbum, y la recomendación musical de hoy es la canción que lanzaron como primer teaser de su álbum.

Marianne, de Fontaines D.C.

Escuchando

Comentarios