Resiliencia en microservicios: Timeouts, Retries y Circuit Breaker

En un sistema distribuido, la red no es confiable y los servicios remotos fallarán tarde o temprano. Si tu servicio depende de otros, no puedes permitir que un fallo en un componente externo desmorone toda tu infraestructura. Los patrones de resiliencia son mecanismos de defensa diseñados para manejar fallos parciales de forma elegante.

Cuando realizas una llamada externa, debes aplicarlos siempre. Si no lo haces, te enfrentarás a fallos en cascada (cascading failures): un servicio lento en la capa inferior retiene tus goroutines, las cuales consumen memoria y descriptores de archivos, hasta que tu propio servicio colapsa por agotamiento de recursos. Para evitar esto, necesitas controlar el tiempo de espera con context.WithTimeout, implementar reintentos con backoff exponencial y jitter para evitar el efecto thundering herd (una avalancha de reintentos simultáneos que golpea un servicio recién recuperado), y usar un circuit breaker para cortar el flujo hacia un servicio que ya sabemos que está caído.

Si la estrategia de reintento falla, siempre debes tener un fallback (un valor por defecto o una respuesta degradada) y asegurarte de que tus operaciones sean idempotentes; de lo contrario, un reintento de una operación de pago que sí tuvo éxito pero falló en la respuesta podría duplicar el cobro al cliente.

package main

import (
	"context"
	"errors"
	"fmt"
	"math/rand"
	"sync"
	"time"
)

// Errores personalizados para nuestro escenario
var (
	ErrServiceUnavailable = errors.New("servicio externo caído")
	ErrCircuitOpen        = errors.New("circuit breaker está abierto")
)

// CircuitBreaker implementa un mecanismo simple de protección.
type CircuitBreaker struct {
	mu           sync.Mutex
	failures     int
	threshold    int
	state        string // "CLOSED", "OPEN", "HALF-OPEN"
	lastFailure  time.Time
	resetTimeout time.Duration
}

func NewCircuitBreaker(threshold int, resetTimeout time.Duration) *CircuitBreaker {
	return &CircuitBreaker{
		threshold:    threshold,
		resetTimeout: resetTimeout,
		state:        "CLOSED",
	}
}

func (cb *CircuitBreaker) Execute(ctx context.Context, fn func() error) error {
	cb.mu.Lock()
	if cb.state == "OPEN" {
		if time.Since(cb.lastFailure) > cb.resetTimeout {
			cb.state = "HALF-OPEN"
		} else {
			cb.mu.Unlock()
			return ErrCircuitOpen
		}
	}
	cb.mu.Unlock()

	err := fn()

	cb.mu.Lock()
	defer cb.mu.Unlock()

	if err != nil {
		cb.failures++
		cb.lastFailure = time.Now()
		if cb.failures >= cb.threshold {
			cb.state = "OPEN"
		}
		return err
	}

	// Si la llamada fue exitosa y estábamos en HALF-OPEN, cerramos el circuito
	if cb.state == "HALF-OPEN" {
		cb.state = "CLOSED"
		cb.failures = 0
	}
	return nil
}

// ExternalService simula un servicio remoto inestable.
type ExternalService struct {
	failCount int
}

func (s *ExternalService) Call(ctx context.Context, idenKey string) error {
	// Simulamos latencia y fallos intermitentes
	select {
	case <-time.After(500 * time.Millisecond):
		if s.failCount < 3 { // Simula que las primeras 3 llamadas fallan
			s.failCount++
			return ErrServiceUnavailable
		}
		fmt.Printf("[ExternalService] Procesando request con key: %s\n", idenKey)
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

// CallWithResilience orquestra la resiliencia: Timeout -> Circuit Breaker -> Retry -> Fallback
func CallWithResilience(ctx context.Context, cb *CircuitBreaker, svc *ExternalService, idenKey string) string {
	const maxRetries = 3
	var lastErr error

	// 1. Implementación de Retry con Exponential Backoff + Jitter
	for i := 0; i < maxRetries; i++ {
		// 2. Timeout por cada intento individual
		attemptCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
		
		// 3. Circuit Breaker para evitar ataques a servicios caídos
		err := cb.Execute(attemptCtx, func() error {
			return svc.Call(attemptCtx, idenKey)
		})
		cancel()

		if err == nil {
			return "Resultado Real"
		}

		lastErr = err
		if errors.Is(err, ErrCircuitOpen) {
			break // Si el circuito está abierto, no reintentamos, esperamos
		}

		// Calcular backoff exponencial con Jitter para evitar el "Thundering Herd"
		// Fórmula: base * 2^attempt + jitter
		backoff := time.Duration(float64(time.Second) * (1 << i))
		jitter := time.Duration(rand.Int63n(int64(backoff / 4)))
		sleepTime := backoff + jitter

		fmt.Printf("  -> Intento %d falló (%v). Reintentando en %v...\n", i+1, err, sleepTime)
		
		select {
		case <-time.After(sleepTime):
		case <-ctx.Done():
			return "Cancelado por el cliente"
		}
	}

	// 4. Fallback: Si todo falla, devolvemos un valor por defecto o caché
	fmt.Printf("  -> Error final: %v. Aplicando Fallback.\n", lastErr)
	return "Valor por defecto (Cache/Fallback)"
}

func main() {
	rand.Seed(time.Now().UnixNano())
	svc := &ExternalService{}
	cb := NewCircuitBreaker(3, 5*time.Second)
	ctx := context.Background()

	// Simulamos una ráfaga de peticiones
	for i := 1; i <= 6; i++ {
		fmt.Printf("\n--- Petición #%d ---\n", i)
		res := CallWithResilience(ctx, cb, svc, fmt.Sprintf("req-%d", i))
		fmt.Printf("Resultado final: %s\n", res)
	}
}

Análisis del código

En el ejemplo, la función CallWithResilience actúa como el orquestador de la resiliencia.

Primero, observa cómo envolvemos la llamada en un bucle de reintentos. Para evitar que todos los clientes reintenten exactamente al mismo tiempo (el efecto thundering herd), calculamos un sleepTime que combina un backoff exponencial (1 << i) con un jitter aleatorio. Esto dispersa la carga de trabajo.

Dentro del bucle, usamos context.WithTimeout para asegurar que ninguna llamada se quede colgada indefinidamente. Es crucial notar que pasamos attemptCtx a svc.Call. Esto permite la cancelación en cascada: si el contexto del cliente se cancela o el timeout se alcanza, la goroutine que está esperando la respuesta de svc.Call se libera inmediatamente gracias al select con <-ctx.Done().

El CircuitBreaker mantiene un estado interno. Cuando cb.Execute detecta que hemos superado el threshold de fallos, el estado cambia a OPEN. En ese punto, cualquier llamada posterior falla instantáneamente con ErrCircuitOpen sin siquiera intentar tocar al servicio externo, dándole tiempo para recuperarse. Solo cuando el resetTimeout ha pasado, permitimos una llamada de prueba (HALF-OPEN) para decidir si cerramos el circuito o lo volvemos a abrir.

Finalmente, si el bucle de reintentos se agota o el circuito está abierto, la función retorna un valor de fallback. En producción, esto podría ser un valor obtenido de un caché como Redis o simplemente una respuesta parcial que no rompa la experiencia del usuario.

El error frecuente

Un error muy común es ignorar el contexto dentro de los bucles de reintento o no pasarlo a las funciones que realizan la I/O.

// ERROR: El timeout no se respeta si la función no escucha el contexto
for i := 0; i < 3; i++ {
    ctx, cancel := context.WithTimeout(context.Background(), time.Second)
    defer cancel() // Error: defer en un bucle puede causar leaks de memoria

    err := svc.Call(ctx) // Si svc.Call no usa ctx internamente, este timeout es inútil
    if err != nil {
        time.Sleep(time.Second) // Bloqueo manual que no escucha cancelaciones
    }
}

Si svc.Call no utiliza el ctx para sus operaciones de red, el context.WithTimeout no servirá de nada y tu goroutine se quedará bloqueada hasta que la conexión de red falle por sí misma. Además, usar defer cancel() dentro de un bucle es un error de gestión de memoria; el defer no se ejecuta hasta que la función completa (en este caso main o la función que lo contenga) termine, acumululando objetos en memoria innecesariamente. Usa siempre un cancel() manual o una función anónima para delimitar el ciclo de vida del contexto.

231

Dejar un comentario

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

Scroll al inicio