Manejo de errores en Go: control explícito y robustez

Capítulo 5

Tiempo estimado de lectura: 8 minutos

+ Ejercicio

El enfoque idiomático: errores como valores y retornos explícitos

En Go, el flujo de errores se maneja devolviendo un valor error junto con el resultado. Esto hace que el control sea explícito: cada llamada decide si propaga, transforma o maneja el error. La regla práctica es simple: si una función puede fallar, devuelve (T, error) (o solo error si no hay resultado).

func ReadConfig(path string) ([]byte, error) {    b, err := os.ReadFile(path)    if err != nil {        return nil, err    }    return b, nil}

Este patrón evita excepciones implícitas y facilita construir software robusto, especialmente en servicios y herramientas de línea de comandos, donde la trazabilidad del fallo es tan importante como el fallo en sí.

Regla de oro: manejar, enriquecer o propagar

Cuando recibes un err, normalmente harás una de estas tres cosas:

  • Manejar: puedes resolver el problema localmente (reintentar, usar un valor por defecto, pedir otra entrada).
  • Enriquecer: añades contexto para que el error sea entendible en capas superiores.
  • Propagar: lo devuelves tal cual si no puedes aportar nada útil.

Envolviendo errores con contexto (wrapping)

El wrapping es clave para la trazabilidad: mantienes el error original y agregas información del punto donde ocurrió. Se hace con fmt.Errorf y %w.

func LoadUserProfile(path string) ([]byte, error) {    b, err := os.ReadFile(path)    if err != nil {        return nil, fmt.Errorf("leyendo perfil %q: %w", path, err)    }    return b, nil}

Esto permite que capas superiores inspeccionen el error original (por ejemplo, “archivo no existe”) sin perder el contexto (“leyendo perfil ...”).

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

Descargar la aplicación

Guía práctica paso a paso: validación de entrada con errores útiles

Un caso real común: validar datos antes de ejecutar lógica de negocio. La idea es devolver errores descriptivos y, cuando convenga, errores tipados para que el llamador pueda reaccionar.

Paso 1: define un error de validación tipado

type ValidationError struct {    Field string    Msg   string}func (e *ValidationError) Error() string {    return fmt.Sprintf("validación: %s %s", e.Field, e.Msg)}

Paso 2: valida y devuelve el error

func ParsePort(s string) (int, error) {    p, err := strconv.Atoi(s)    if err != nil {        return 0, &ValidationError{Field: "port", Msg: "no es un número"}    }    if p < 1 || p > 65535 {        return 0, &ValidationError{Field: "port", Msg: "fuera de rango (1-65535)"}    }    return p, nil}

Paso 3: inspecciona con errors.As para decidir qué hacer

p, err := ParsePort(input)if err != nil {    var ve *ValidationError    if errors.As(err, &ve) {        // Error recuperable: pedir al usuario que corrija el campo        fmt.Printf("Entrada inválida en %s: %s\n", ve.Field, ve.Msg)        return    }    // Otro tipo de error inesperado: propagar o registrar    log.Printf("error inesperado: %v", err)    return}

Observa el objetivo: errores recuperables (entrada inválida) se manejan localmente; errores no previstos se registran/propagan.

Errores sentinela vs errores tipados: cuándo usar cada uno

Errores sentinela (variables)

Un error sentinela es una variable exportada o interna que representa una condición específica. Útil cuando el llamador solo necesita saber “qué pasó” sin más datos.

var ErrNotFound = errors.New("no encontrado")func FindUser(id string) (User, error) {    if id == "" {        return User{}, ErrNotFound    }    // ...    return User{}, ErrNotFound}

Para compararlos correctamente (incluyendo wrapping), usa errors.Is:

u, err := FindUser(id)if err != nil {    if errors.Is(err, ErrNotFound) {        // responder 404, o mostrar mensaje al usuario        return    }    return}

Errores tipados (structs) con datos

Cuando necesitas adjuntar información (campo, código, operación, endpoint), un tipo es mejor que un sentinela. Se inspecciona con errors.As.

type NotFoundError struct {    Resource string    ID       string}func (e *NotFoundError) Error() string {    return fmt.Sprintf("%s con id=%s no existe", e.Resource, e.ID)}

Acceso a archivos: distinguir causas y mejorar trazabilidad

En IO es frecuente querer distinguir “no existe” de “permiso denegado” y, a la vez, conservar el contexto de la operación.

func ReadReport(path string) ([]byte, error) {    b, err := os.ReadFile(path)    if err != nil {        return nil, fmt.Errorf("leyendo reporte %q: %w", path, err)    }    return b, nil}

En el llamador, puedes inspeccionar el error original usando helpers del paquete os (que funcionan incluso si el error está envuelto):

b, err := ReadReport("./reports/today.txt")if err != nil {    switch {    case os.IsNotExist(err):        log.Printf("reporte no encontrado: %v", err)    case os.IsPermission(err):        log.Printf("sin permisos: %v", err)    default:        log.Printf("fallo leyendo reporte: %v", err)    }    return}

El mensaje final tendrá contexto (“leyendo reporte ...”) y podrás clasificar la causa (“no existe”, “permiso”).

Errores de red: timeouts, cancelación y reintentos

En red, muchos errores son recuperables (reintentar) y otros deben cortar rápido (context cancelado). Es buena práctica usar context.Context para cancelación y deadlines, y envolver errores con la operación que falló.

func FetchJSON(ctx context.Context, url string) ([]byte, error) {    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)    if err != nil {        return nil, fmt.Errorf("creando request: %w", err)    }    resp, err := http.DefaultClient.Do(req)    if err != nil {        return nil, fmt.Errorf("haciendo GET %s: %w", url, err)    }    defer resp.Body.Close()    if resp.StatusCode < 200 || resp.StatusCode >= 300 {        return nil, fmt.Errorf("GET %s: status %d", url, resp.StatusCode)    }    b, err := io.ReadAll(resp.Body)    if err != nil {        return nil, fmt.Errorf("leyendo body %s: %w", url, err)    }    return b, nil}

Inspección: distinguir timeout de cancelación

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)defer cancel()_, err := FetchJSON(ctx, "https://api.example.com/data")if err != nil {    if errors.Is(err, context.DeadlineExceeded) {        log.Printf("timeout: %v", err)        return    }    if errors.Is(err, context.Canceled) {        log.Printf("cancelado: %v", err)        return    }    // También puedes detectar errores de red con net.Error (cuando aplique)    var ne net.Error    if errors.As(err, &ne) && ne.Timeout() {        log.Printf("timeout de red: %v", err)        return    }    log.Printf("error de red: %v", err)}

Reintento simple con backoff (solo para errores recuperables)

Un reintento debe ser conservador: no reintentes si el contexto está cancelado o si el error no es transitorio. Ejemplo minimalista:

func FetchWithRetry(ctx context.Context, url string, attempts int) ([]byte, error) {    var last error    delay := 200 * time.Millisecond    for i := 0; i < attempts; i++ {        b, err := FetchJSON(ctx, url)        if err == nil {            return b, nil        }        if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {            return nil, err        }        // Si detectas timeout de red, podría ser transitorio        var ne net.Error        if errors.As(err, &ne) && ne.Timeout() {            last = err        } else {            // No parece recuperable: no reintentar            return nil, err        }        t := time.NewTimer(delay)        select {        case <-ctx.Done():            t.Stop()            return nil, ctx.Err()        case <-t.C:        }        delay *= 2    }    return nil, fmt.Errorf("agotados reintentos para %s: %w", url, last)}

Cuándo usar panic/recover: condiciones fatales vs errores recuperables

panic no es el mecanismo normal de errores en Go. Se reserva para condiciones fatales o programming errors: invariantes rotas, estados imposibles, índices fuera de rango por bug, nil inesperado, etc. En cambio, fallos esperables (archivo no existe, input inválido, timeout) deben ser error.

  • Usa error cuando el llamador puede decidir qué hacer.
  • Usa panic cuando continuar sería incorrecto y el problema es un bug o una corrupción de estado.
  • Usa recover solo en bordes controlados (por ejemplo, un servidor para evitar que una petición tumbe el proceso) y siempre registrando lo ocurrido.

Ejemplo: middleware de recuperación en un servidor HTTP

func RecoverMiddleware(next http.Handler) http.Handler {    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {        defer func() {            if rec := recover(); rec != nil {                // Registrar con contexto de request; en producción, añade stacktrace si lo necesitas                log.Printf("panic en %s %s: %v", r.Method, r.URL.Path, rec)                http.Error(w, "error interno", http.StatusInternalServerError)            }        }()        next.ServeHTTP(w, r)    })}

Este patrón evita caídas del proceso por un bug en una ruta. Aun así, el objetivo es corregir el bug; no “normalizar” el uso de panic como control de flujo.

Diseño de mensajes de error: claridad, contexto y consistencia

Un buen error debe ayudar a responder: ¿qué operación falló?, ¿sobre qué recurso?, ¿por qué?, ¿es recuperable? Recomendaciones prácticas:

  • Incluye la operación en el mensaje: “abriendo archivo”, “haciendo GET”, “parseando configuración”.
  • Incluye identificadores relevantes: rutas, URLs, IDs (evita datos sensibles).
  • Envuelve el error original con %w para no perder la causa.
  • Evita mayúsculas iniciales y puntos finales en mensajes de error (convención común en Go).
SituaciónMejor prácticaEjemplo
Fallo en capa baja (IO)Envolver con contextofmt.Errorf("abriendo %q: %w", path, err)
ValidaciónError tipado con datos&ValidationError{Field:"email", Msg:"formato inválido"}
Condición conocidaSentinela + errors.IsErrNotFound
Bug/invariante rotapanic (y recover en borde)panic("estado imposible")

Ahora responde el ejercicio sobre el contenido:

Si una función envuelve un error con fmt.Errorf usando %w para agregar contexto, ¿qué ventaja principal ofrece este enfoque al manejar el error en capas superiores?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

El wrapping con %w agrega contexto manteniendo la causa original. Así, capas superiores pueden clasificar el error (p. ej., con errors.Is o helpers como os.IsNotExist) sin perder información de dónde falló.

Siguiente capítulo

Concurrencia en Go: goroutines, canales y sincronización

Arrow Right Icon
Portada de libro electrónico gratuitaGo desde Cero: Programación Moderna, Rápida y Escalable
42%

Go desde Cero: Programación Moderna, Rápida y Escalable

Nuevo curso

12 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.