Diseño de jerarquías de errores y wrapping en Go

El errorwrapping es la técnica de envolver un error existente con un nuevo error que añade contexto semántico sin destruir la identidad del error original. En Go, esto se logra mediante el uso del verbo %w en fmt.Errorf, lo que permite que el nuevo error implemente la interfaz Unwrap. Esto es fundamental porque permite que las funciones de la biblioteca estándar, como errors.Is y errors.As, puedan navegar hacia atrás a través de la cadena de errores para encontrar la causa raíz.

Cuando diseñas una aplicación distribuida o un microservicio, no debes tratar todos los errores como una simple cadena de texto. El objetivo es construir una jerarquía clara: los errores de infraestructura (como una conexión perdida a PostgreSQL) no deben ser lo mismo que los errores de dominio (como “usuario no encontrado”) o los errores de validación (como “el email es inválido”). Si envuelves un error repitiendo su propio mensaje, como fmt.Errorf("error: %w", err), estás añadiendo ruido innecesario que dificulta la lectura de logs y no aporta valor al depurador. La clave está en describir la acción que falló: fmt.Errorf("fetching user profile: %w", err).

Si implementas mal este sistema, especialmente si usas %v en lugar de %w, romperás la capacidad de tu código para tomar decisiones lógicas basadas en el tipo de error. Si un manejador de HTTP recibe un error de base de datos y no puede determinar mediante errors.Is que se trata de un error de “registro no encontrado”, terminará devolviendo un 500 Internal Server Error en lugar de un 404 Not Found, ocultando información crítica y rompiendo la semántica de tu API.

package main

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

// --- Capa de Dominio ---

// ErrUserNotFound es un error de dominio que indica que el recurso no existe.
var ErrUserNotFound = errors.New("user not found")

// ValidationError representa un error de reglas de negocio.
type ValidationError struct {
	Field string
	Reason string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("invalid field %s: %s", e.Field, e.Reason)
}

// --- Capa de Infraestructura ---

// ErrDatabase es un error de infraestructura simulado.
var ErrDatabase = errors.New("database connection timeout")

func mockDBQuery(userID int) error {
	if userID == 0 {
		// Simulamos que el error viene de un driver de base de datos
		return fmt.Errorf("sql: %w", ErrDatabase)
	}
	if userID == 404 {
		return ErrUserNotFound
	}
	return nil
}

// --- Capa de Aplicación / Caso de Uso ---

func GetUserHandler(id int) error {
	err := mockDBQuery(id)
	if err != nil {
		// Agregamos contexto sobre la acción que falló.
		// No repetimos el mensaje del error, solo describimos el paso fallido.
		return fmt.Errorf("failed to retrieve user %d: %w", id, err)
	}
	return nil
}

func main() {
	userIDs := []int{404, 0, 1}

	for _, id := range userIDs {
		err := GetUserHandler(id)
		if err == nil {
			fmt.Printf("✅ Usuario %d encontrado\n", id)
			continue
		}

		// 1. Decidir mediante errores de dominio (Is)
		if errors.Is(err, ErrUserNotFound) {
			fmt.Printf("⚠️  Caso 404: El usuario %d no existe en el sistema\n", id)
		} else if errors.Is(err, ErrDatabase) {
			// 2. Identificar fallos de infraestructura para métricas/reintentos
			fmt.Printf("🚨 Error Crítico: Problema de infraestructura detectado: %v\n", err)
		} else {
			// 3. Manejar otros errores
			fmt.Printf("❓ Error inesperado: %v\n", err)
		}
	}

	// Ejemplo de extracción de datos de error mediante errores de validación (As)
	errVal := &ValidationError{Field: "email", Reason: "malformat"}
	errWrapped := fmt.Errorf("validation failed: %w", errVal)

	var vErr *ValidationError
	if errors.As(errWrapped, &vErr) {
		fmt.Printf("🔍 Extracción de datos: El campo '%s' falló por: %s\n", vErr.Field, vErr.Reason)
	}
}

Desglose del ejemplo

En el main, observa cómo la cadena de errores se mantiene intacta gracias a %w. Cuando llamamos a GetUserHandler(0), el flujo es el siguiente: mockDBQuery devuelve un error que envuelve ErrDatabase. Luego, GetUserHandler lo envuelve de nuevo con el contexto "failed to retrieve user 0".

Al llegar a errors.Is(err, ErrDatabase), el runtime de Go no compara simplemente strings; recorre la cadena de punteros de los errores hasta encontrar una coincidencia con el valor de ErrDatabase. Esto es lo que permite que la lógica de negocio decida si debe reintentar la operación (porque es un problema de red/DB) o si debe fallar definitivamente.

Por otro lado, el uso de errors.As con ValidationError demuestra cómo podemos recuperar tipos personalizados. A diferencia de errors.Is, que busca un valor específico, errors.As intenta realizar un type assertion a través de toda la cadena de wrapping. Si el error es (o contiene) un puntero a ValidationError, se asigna el valor a la variable vErr, permitiéndonos acceder a sus campos Field y Reason.

El error frecuente

Un error muy común en revisiones de código es el “redundant wrapping”. Fíjate en este patrón:

// ❌ ERROR: Redundante y ruidoso
if err != nil {
    return fmt.Errorf("error: %w", err) 
}

Si err ya contiene el mensaje "file not found", el error resultante será "error: file not found". Si este error pasa por tres capas de tu aplicación, terminarás con logs absurdos como:
error: error: failed to load: error: file not found.

La regla de oro es: el wrapping debe aportar contexto de la capa actual, no repetir el error anterior. Si la función se llama LoadConfig, el contexto debe ser "config: %w", asumiendo que err ya dice qué falló dentro del proceso de lectura.

209

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio