El patrón de reintento: creación de microservicios resilientes

El patrón de reintento: creación de microservicios resilientes

En una arquitectura de microservicios, los servicios se comunican a través de una red en lugar de llamadas en memoria. Si bien este desacoplamiento permite un escalamiento horizontal masivo e implementaciones independientes, también introduce una vulnerabilidad importante: la red no es confiable.

En cualquier momento, un servicio descendente puede experimentar una breve falla en la red, un pico temporal de CPU, una rápida contención de bloqueo de la base de datos o un reinicio continuo de la actualización. Estas fallas temporales se conocen como fallas transitorias.

Si su servicio arroja inmediatamente un error y falla la solicitud en el momento en que falla una llamada posterior, crea una experiencia de usuario frágil. En cambio, muchos de estos errores transitorios se pueden resolver automáticamente esperando un momento y volviendo a intentarlo. Aquí es donde entra en juego el Patrón de reintento.

En esta guía, exploraremos el patrón Retry, cómo funciona internamente, los peligros de las implementaciones ingenuas y cómo implementarlo correctamente en Java (Resilience4j) y Go.


La analogía del mundo real: volver a marcar una línea ocupada

Imagina que estás intentando llamar a un amigo. Marcas su número, pero recibes una señal de ocupado porque actualmente están en otra llamada.

¿Te rindes inmediatamente, eliminas su contacto y asumes que nunca podrás volver a hablar con él? Por supuesto que no. Cuelgas, esperas un minuto y vuelves a marcar su número. Si todavía están ocupados, puede esperar cinco minutos antes de volver a intentarlo.

Finalmente, su llamada finaliza y su reintento tiene éxito.

Diagrama de arquitectura del patrón de reintento que muestra el intento de solicitud, el retraso de retroceso y el segundo intento de reintento exitoso

En microservicios:

  • La llamada es una solicitud de API a un servicio descendente.
  • La señal de ocupado es un error de red transitorio o una respuesta 503 Service Unavailable.
  • La rellamada es un reintento.
  • El tiempo de espera es la duración del retraso.

El peligro: reintentos ingenuos y “tormentas de reintentos”

Implementar un mecanismo de reintento parece trivial a primera vista: simplemente envuelva su llamada HTTP en un bucle for y siga intentándolo hasta que tenga éxito. Sin embargo, una implementación ingenua de reintento puede transformar fácilmente un problema menor en una interrupción catastrófica en todo el sistema.

Imagine un servicio descendente que tiene dificultades debido a un aumento repentino de tráfico. Su base de datos se está ejecutando con un uso de CPU del 99 % y las solicitudes están empezando a agotar el tiempo de espera.

Si 100 servicios de cliente detectan un tiempo de espera e inmediatamente lo reintentan 3 veces sin esperar, de repente triplicarán el volumen de tráfico que llega al servicio descendente que ya está estrangulando. Esta amplificación repentina del tráfico se conoce como Tormenta de reintentos (o problema de Thundering Herd).

En lugar de ayudar a que el servicio descendente se recupere, sus reintentos seguirán presionándolo hacia abajo, evitando que se ponga al día.


La solución: retroceso y fluctuación

Para evitar tormentas de reintentos, un sistema resiliente debe utilizar dos estrategias esenciales: Retroceso y Jitter.

1. Estrategias de retroceso

El retroceso dicta cuánto tiempo debe esperar un cliente antes de realizar un reintento posterior.

  • Retroceso fijo: el cliente espera una cantidad de tiempo constante (por ejemplo, exactamente 200 ms) entre intentos. Si bien es simple, aún corre el riesgo de generar picos de tráfico sincronizados.
  • Retroceso exponencial: el tiempo de espera aumenta exponencialmente con cada intento fallido (por ejemplo, 100 ms, 200 ms, 400 ms, 800 ms). Esto le da al servicio descendente progresivamente más tiempo para recuperarse a medida que persiste el fallo.

2. Jitter (aleatoriedad)

Incluso con un retroceso exponencial, si un problema en la red provoca que 1000 solicitudes fallen exactamente en el mismo instante, los 1000 clientes calcularán exactamente el mismo retraso de retroceso. En consecuencia, todos volverán a intentarlo simultáneamente en oleadas, alcanzando el servicio descendente con picos sincronizados.

Jitter resuelve esto agregando una variación aleatoria al retraso de retroceso.

Without Jitter (Synchronized Waves):
Time: 0ms   -> [1000 requests fail]
Time: 100ms -> [1000 retries hit simultaneously]
Time: 200ms -> [1000 retries hit simultaneously]

With Jitter (Distributed Traffic):
Time: 0ms   -> [1000 requests fail]
Time: 92ms  -> [85 retries]
Time: 105ms -> [120 retries]
Time: 118ms -> [95 retries]
... (Traffic is smoothed out over time)

Al distribuir los reintentos en un intervalo aleatorio, la carga en el servicio descendente se suaviza, lo que le permite recuperarse sin problemas.


La regla de oro de los reintentos: Idempotencia

Antes de aplicar reintentos a cualquier punto final de API, debe hacer una pregunta fundamental: ¿Es esta operación idempotente?

Una operación idempotente es aquella en la que realizar varias solicitudes idénticas tiene exactamente el mismo efecto que realizar una sola solicitud.

  • Idempotente: leer un perfil de usuario (GET /users/123), actualizar un campo de correo electrónico completo (PUT /users/123/email) o eliminar un elemento (DELETE /items/456).
  • No Idempotente: Crear un nuevo pedido (POST /orders) o procesar un cargo de tarjeta de crédito (POST /payments).

Suponga que llama a POST /payments para cobrarle a un cliente $50. La pasarela de pago descendente recibe la solicitud, carga la tarjeta de crédito correctamente, pero luego se produce una falla en la red antes de que pueda enviarle la respuesta 200 OK.

Su servicio registra un tiempo de espera, asume que la llamada falló y vuelve a intentarlo automáticamente. Si la pasarela de pago no está diseñada para manejar solicitudes duplicadas, se le cobrará al cliente dos veces.

[!ADVERTENCIA] Nunca vuelva a intentar operaciones no idempotentes a menos que el servicio descendente admita Claves de idempotencia (identificadores de solicitud únicos utilizados para detectar y descartar transacciones duplicadas).


Ejemplos de implementación

Veamos cómo implementar el patrón de reintento en Java y Go.

1. Java (Resilience4j y Spring Boot)

Resilience4j proporciona un módulo de reintento robusto y altamente configurable. A continuación se explica cómo configurarlo para un servicio de facturación externo.

Configuración (application.yml)

resilience4j.retry:
  instances:
    billingService:
      maxAttempts: 3
      waitDuration: 100ms
      enableExponentialBackoff: true
      exponentialBackoffMultiplier: 2.0
      enableRandomizedWait: true
      randomizedWaitFactor: 0.5
      retryExceptions:
        - org.springframework.web.client.ResourceAccessException
        - io.netty.channel.ConnectTimeoutException
      ignoreExceptions:
        - org.springframework.web.client.HttpClientErrorException # E.g., 400 Bad Request shouldn't be retried

Implementación del código

import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
public class PaymentProcessor {

    private final RestTemplate restTemplate;

    public PaymentProcessor(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    // Apply the configured retry policy with a fallback method
    @Retry(name = "billingService", fallbackMethod = "billingFallback")
    public String chargeUser(BillingRequest request) {
        return restTemplate.postForObject("http://billing-service/charge", request, String.class);
    }

    // Executed when all retry attempts fail
    public String billingFallback(BillingRequest request, Throwable throwable) {
        return "Billing service is currently unavailable. Your transaction will be queued.";
    }
}

2. Ir (Golang)

En Go, podemos escribir un corredor de reintento elegante que presente retroceso exponencial y fluctuación aleatoria utilizando temporizadores de biblioteca estándar.

package main

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

// RetryConfig holds the policies for our retry attempts
type RetryConfig struct {
	MaxAttempts int
	MinBackoff  time.Duration
	MaxBackoff  time.Duration
}

// Execute runs the operation using exponential backoff with full jitter
func Execute(ctx context.Context, config RetryConfig, operation func() error) error {
	var err error
	
	for attempt := 1; attempt <= config.MaxAttempts; attempt++ {
		err = operation()
		if err == nil {
			return nil // Success!
		}

		if attempt == config.MaxAttempts {
			break
		}

		// Calculate exponential backoff
		// wait = min(MaxBackoff, MinBackoff * 2^(attempt-1))
		backoff := config.MinBackoff * (1 << (attempt - 1))
		if backoff > config.MaxBackoff || backoff <= 0 {
			backoff = config.MaxBackoff
		}

		// Apply Full Jitter: wait randomly between 0 and backoff
		jitter := time.Duration(rand.Int63n(int64(backoff)))

		fmt.Printf("[Attempt %d/%d] Failed: %v. Retrying in %v...\n", attempt, config.MaxAttempts, err, jitter)

		select {
		case <-time.After(jitter):
		case <-ctx.Done():
			return ctx.Err()
		}
	}

	return fmt.Errorf("operation failed after %d attempts: %w", config.MaxAttempts, err)
}

func main() {
	config := RetryConfig{
		MaxAttempts: 4,
		MinBackoff:  100 * time.Millisecond,
		MaxBackoff:  2000 * time.Millisecond,
	}

	// Mock function that fails 3 times and succeeds on the 4th
	attempts := 0
	mockAPI := func() error {
		attempts++
		if attempts < 4 {
			return errors.New("network timeout (503)")
		}
		return nil
	}

	ctx := context.Background()
	err := Execute(ctx, config, mockAPI)
	if err != nil {
		fmt.Printf("Final Outcome: %v\n", err)
	} else {
		fmt.Println("Final Outcome: Successfully connected on attempt", attempts)
	}
}

Reintentar frente a disyuntor: ¿cuándo utilizar cuál?

Los desarrolladores suelen confundir el Patrón de reintento con el Patrón de disyuntor. Si bien ambos apuntan a mejorar la resiliencia del sistema, manejan tipos de fallas fundamentalmente diferentes:

Métrica Patrón de reintento Patrón de disyuntor
Objetivo principal Se recupera automáticamente de fallas transitorias (de corta duración). Evita que los errores persistentes (de larga duración) eliminen a la persona que llama.
Estrategia Vuelva a intentarlo inmediatamente o después de una breve espera. Falla rápidamente inmediatamente sin invocar el servicio de destino.
Impacto aguas abajo Aumenta temporalmente la carga en el servicio descendente. Protege el servicio descendente del tráfico, permitiéndole recuperarse.
Disparadores típicos Breves desconexiones de red, tiempos de espera de bases de datos, caídas de sockets. El servicio descendente está completamente inactivo y devuelve 500 continuos o tiempos de espera.

La pareja poderosa: combinando ambos

En los sistemas de producción, estos dos patrones están diseñados para usarse juntos.

Cuando realiza una solicitud descendente, debe pasar primero por el Disyuntor y luego por el contenedor Reintentar.

Si se produce una falla transitoria, el mecanismo de reintento la intercepta y vuelve a intentarlo. Sin embargo, si el servicio descendente está inactivo, las fallas se acumularán. El disyuntor detecta las fallas consecutivas, se “dispara” y bloquea todas las llamadas futuras.

Ahora, cuando intenta llamar al servicio, el disyuntor falla rápidamente de inmediato, evitando por completo el ciclo de reintento y ahorrando recursos informáticos.


Mejores prácticas para el patrón de reintento

  1. Solo reintentar errores transitorios: inspeccione los códigos de estado HTTP. Vuelva a intentar 503 Service Unavailable, 429 Too Many Requests (respetando los encabezados Retry-After si están presentes) y los tiempos de espera de la red. NO vuelva a intentar 400 Bad Request, 401 Unauthorized o 404 Not Found; estos nunca tendrán éxito al reintentar.
  2. Aplicar Jitter: nunca utilice un temporizador de reintento fijo sin introducir jitter aleatorio.
  3. Limitar el máximo de intentos: deje de reintentar después de un umbral razonable (normalmente de 3 a 5 intentos). Los reintentos ilimitados consumen recursos y reducen la latencia.
  4. Tenga cuidado con los reintentos en cascada: si el servicio A llama al servicio B (que reintenta 3 veces) y el servicio B llama al servicio C (que reintenta 3 veces), una falla en C puede resultar en $3 \times 3 = 9$ llamadas en cascada. Sólo aplique reintentos en los límites donde tengan más sentido.
  5. Establezca siempre tiempos de espera estrictos: asegúrese de que los tiempos de espera de su red sean más cortos que los períodos de espera, para que los subprocesos no se retengan indefinidamente.

Conclusión

El Patrón de reintento es una poderosa primera línea de defensa contra la falta de confiabilidad de la red en arquitecturas de microservicios. Cuando se combina con Retroceso exponencial, Jitter y un Disyuntor, puede proteger sus sistemas contra cortes en cascada y crear una experiencia fluida y de autorreparación para sus usuarios.