Gestión de errores y cancelación con errgroup

errgroup.Group es la evolución natural de sync.WaitGroup cuando trabajas con tareas que pueden fallar. Mientras que un WaitGroup estándar solo te permite esperar a que un conjunto de goroutines termine, errgroup añade dos capas críticas: captura el primer error que ocurra en cualquiera de las goroutines y, si utilizas WithContext, propaga la cancelación de ese error a todas las demás tareas.

Esto funciona gracias a una implementación interna que combina un sync.WaitGroup con un context.Context y un sync.Once para asegurar que solo el primer error sea el que se devuelva al llamar a Wait(). Es la herramienta ideal cuando implementas patrones fan-out (disparar múltiples tareas en paralelo), como realizar varias llamadas a APIs externas simultáneamente para construir una respuesta única. Si una de esas llamadas falla, no tiene sentido seguir esperando las otras; errgroup te permite abortar el resto de forma coordinada. Sin embargo, si intentas usar un WaitGroup tradicional para este propósito, te verás obligado a gestionar manualmente un canal de errores y lidiar con la complejidad de cancelar las tareas restantes, lo que suele derivar en fugas de goroutines (leaks) o código propenso a race conditions.

package main

import (
	"context"
	"errors"
	"fmt"
	"time"

	"golang.org/x/sync/errgroup"
)

func main() {
	// Creamos un contexto raíz con un timeout de seguridad.
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	// errgroup.WithContext devuelve un grupo y un context derivado.
	// Si una función en el grupo retorna un error, el contexto 'ctxErr' 
	// se cancela inmediatamente, notificando a las otras goroutines.
	g, ctxErr := errgroup.WithContext(ctx)

	// Limitamos la concurrencia a máximo 2 goroutines simultáneas [disponible desde Go 1.20].
	// Esto evita saturar recursos o exceder límites de rate limiting de servicios externos.
	g.SetLimit(2)

	endpoints := []string{"api/usuarios", "api/pedidos", "api/productos", "api/error-crítico"}

	for _, endpoint := range endpoints {
		// Capturamos la variable en el scope del bucle para evitar el error 
		// de captura de variable de iteración (comportamiento corregido en Go 1.22).
		endpoint := endpoint

		// g.Go lanza la goroutine y gestiona internamente el WaitGroup.
		g.Go(func() error {
			return fetchData(ctxErr, endpoint)
		})
	}

	// Wait() bloquea hasta que todas las goroutines terminen o una retorne un error.
	// Retorna el primer error no-nil que haya ocurrido.
	if err := g.Wait(); err != nil {
		fmt.Printf("La operación falló: %v\n", err)
	} else {
		fmt.Println("Todas las tareas completadas con éxito.")
	}
}

func fetchData(ctx context.Context, url string) error {
	fmt.Printf("Iniciando: %s\n", url)

	// Simulamos una operación de red que respeta el contexto.
	select {
	case <-time.After(1 * time.Second):
		if url == "api/error-crítico" {
			return errors.New("fallo en " + url)
		}
		fmt.Printf("Finalizado: %s\n", url)
		return nil
	case <-ctx.Done():
		// Si el contexto se cancela (porque otra goroutine falló), 
		// debemos salir inmediatamente para evitar fugas.
		fmt.Printf("Abortando task %s por cancelación\n", url)
		return ctx.Err()
	}
}

Análisis del ejemplo

El programa utiliza errgroup.WithContext(ctx) para crear un entorno de ejecución coordinado. La variable ctxErr es la clave: no es el contexto original, sino uno que tiene un “interruptor de emergencia” conectado al grupo.

Cuando g.Go ejecuta fetchData para el endpoint "api/error-crítico", esa función retorna un error. En ese instante, errgroup hace dos cosas:
1. Marca el error para que g.Wait() lo devuelva.
2. Cancela ctxErr.

Gracias a que las otras goroutines están escuchando <-ctx.Done() dentro de un select, detectan la cancelación casi instantáneamente. Esto evita que las tareas que aún no han terminado sigan consumiendo tiempo o recursos innecesariamente. Además, g.SetLimit(2) asegura que, aunque tengamos 4 URLs, el runtime no intente lanzar todas a la vez si el límite fuera más estricto, gestionando la cola de ejecución de forma interna.

El uso de g.Wait() es el punto de sincronización final. A diferencia de un WaitGroup donde tendrías que llamar a wg.Wait() y luego revisar tus propios canales de error, errgroup simplifica el flujo de control: o todo sale bien, o recibes el primer culpable.

El error frecuente

El error más peligroso con errgroup es no hacer que las tareas sean “cooperativas” con el contexto. Si escribes una función que no escucha ctx.Done(), la goroutine se vuelve un “zombie”.

// ERROR: Esta goroutine ignorará la cancelación de errgroup
g.Go(func() error {
    // Si otra goroutine falla, g.Wait() retornará de inmediato,
    // pero esta función seguirá bloqueada aquí por 10 segundos.
    time.Sleep(10 * time.Second) 
    return nil
})

Si ocurre un error en una tarea, errgroup cancela el contexto y g.Wait() libera al hilo principal, pero la goroutine anterior seguirá ocupando memoria y ocupando un slot en el semáforo de SetLimit hasta que termine su operación. Si esto ocurre bajo alta carga, tu aplicación experimentará un agotamiento de recursos progresivo y fallos de memoria que son extremadamente difíciles de rastrear en producción.

148

Dejar un comentario

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

Scroll al inicio