StackPractices
advanced Por Mathias Paulenko

Diseñar Microservicios Resilientes con Circuit Breakers,

Cómo construir sistemas distribuidos tolerantes a fallos usando patrones de microservicios incluyendo circuit breakers, bulkheads, retries con backoff y sagas para gestión de transacciones.

Visión general

Las arquitecturas de microservicios descomponen aplicaciones en servicios independientemente desplegables, cada uno poseyendo un contexto delimitado y comunicándose vía llamadas de red. Esta descomposición habilita autonomía de equipo, diversidad tecnológica y escalado independiente. Pero introduce un problema fundamental: la red es poco confiable. Cada llamada inter-servicio es un punto potencial de fallo — picos de latencia, fallos en cascada, fallas parciales e inconsistencia durante transacciones distribuidas.

Los patrones de resiliencia protegen el sistema de estos modos de fallo. Un circuit breaker deja de enviar requests a un servicio fallido, dándole tiempo para recuperarse. Un bulkhead aísla fallos para que no consuman todos los recursos. Los retries con backoff exponencial manejan fallos transitorios sin sobrecargar servicios en dificultades. El patrón saga reemplaza transacciones distribuidas con secuencias de transacciones locales coordinadas vía eventos. A continuacion se cubre la implementación de estos patrones core en múltiples lenguajes y frameworks.

Cuándo usarlo

Usa esta receta cuando:

  • Migrando de un monolito a una arquitectura distribuida con 5+ servicios. Consulta Guía de Migración para estrategias de migración.
  • Experimentando fallos en cascada donde un servicio lento degrada todo el sistema
  • Implementando flujos de pago, gestión de inventario o procesamiento de órdenes entre servicios. Consulta Saga Pattern para transacciones distribuidas.
  • Operando servicios con diferentes SLAs de confiabilidad en infraestructura compartida
  • Construyendo plataformas donde servicios individuales deben fallar sin impactar el conjunto

Solución

Circuit Breaker (Python)

import time
from enum import Enum

class CircuitState(Enum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"

class CircuitBreaker:
    def __init__(self, failure_threshold=5, recovery_timeout=30, half_open_max_calls=3):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.half_open_max_calls = half_open_max_calls
        self.state = CircuitState.CLOSED
        self.failure_count = 0
        self.last_failure_time = None
        self.half_open_calls = 0

    def call(self, func, *args, **kwargs):
        if self.state == CircuitState.OPEN:
            if time.time() - self.last_failure_time > self.recovery_timeout:
                self.state = CircuitState.HALF_OPEN
                self.half_open_calls = 0
            else:
                raise Exception("Circuit breaker is OPEN")

        if self.state == CircuitState.HALF_OPEN:
            if self.half_open_calls >= self.half_open_max_calls:
                raise Exception("Circuit breaker is HALF_OPEN")
            self.half_open_calls += 1

        try:
            result = func(*args, **kwargs)
            self._on_success()
            return result
        except Exception as e:
            self._on_failure()
            raise e

    def _on_success(self):
        self.failure_count = 0
        if self.state == CircuitState.HALF_OPEN:
            self.state = CircuitState.CLOSED

    def _on_failure(self):
        self.failure_count += 1
        self.last_failure_time = time.time()
        if self.failure_count >= self.failure_threshold:
            self.state = CircuitState.OPEN

breaker = CircuitBreaker(failure_threshold=3, recovery_timeout=10)

def get_user_profile(user_id):
    pass

try:
    profile = breaker.call(get_user_profile, user_id=123)
except Exception as e:
    profile = get_cached_profile(123)

Retry con Backoff Exponencial (JavaScript / p-retry)

const pRetry = require('p-retry');

async function callPaymentService(orderId) {
  const response = await fetch(`https://payments.internal/api/charge/${orderId}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
  });
  if (!response.ok) throw new Error(`Payment service returned ${response.status}`);
  return response.json();
}

const chargeWithRetry = async (orderId) => {
  return pRetry(() => callPaymentService(orderId), {
    retries: 5,
    factor: 2,
    minTimeout: 1000,
    maxTimeout: 30000,
    randomize: true,
    retryIf: (error) => error.message.includes('5') || error.code === 'ECONNREFUSED',
  });
};

Patrón Saga para Transacciones Distribuidas (TypeScript)

interface SagaStep {
  execute(): Promise<void>;
  compensate(): Promise<void>;
}

class OrderSaga {
  private steps: SagaStep[] = [];
  private completedSteps: SagaStep[] = [];

  addStep(step: SagaStep) { this.steps.push(step); return this; }

  async execute() {
    for (const step of this.steps) {
      try {
        await step.execute();
        this.completedSteps.push(step);
      } catch (error) {
        for (const completed of this.completedSteps.reverse()) {
          await completed.compensate();
        }
        throw new Error(`Saga failed: ${error}`);
      }
    }
  }
}

const orderSaga = new OrderSaga()
  .addStep({
    execute: () => inventoryService.reserve(order.items),
    compensate: () => inventoryService.release(order.items),
  })
  .addStep({
    execute: () => paymentService.charge(order.total),
    compensate: () => paymentService.refund(order.total),
  })
  .addStep({
    execute: () => shippingService.createShipment(order),
    compensate: () => shippingService.cancelShipment(order),
  });

await orderSaga.execute();

Explicación

  • Circuit breaker: previene fallos en cascada deteniendo requests a un servicio fallido. Cuando los fallos exceden un umbral, el breaker se abre y retorna errores inmediatamente. Después de un timeout, entra en estado half-open permitiendo requests limitados de prueba. Si estos tienen éxito, se cierra nuevamente. Esto da a servicios sobrecargados tiempo para recuperarse.
  • Backoff exponencial: reintentar inmediatamente después de un fallo frecuentemente golpea el mismo servicio en dificultades. El backoff incrementa el delay entre retries exponencialmente (1s, 2s, 4s, 8s… ), distribuyendo la carga y permitiendo recuperación. Agregar jitter previene tormentas de retry sincronizadas.
  • Patrón bulkhead: aísla fallos limitando recursos (threads, conexiones, memoria) asignados a cada dependencia de servicio.
  • Patrón saga: las transacciones ACID distribuidas son impracticables entre microservicios. Las sagas descomponen una transacción de negocio en transacciones locales, cada una con un rollback compensatorio. Si el paso 3 falla, los pasos 2 y 1 se deshacen, manteniendo consistencia eventual.

Variantes

PatrónManejo de fallosConsistenciaComplejidadMejor para
Circuit breakerFail rápidoN/ABajaProtección contra sobrecarga
Retry + backoffRecuperación transitoriaN/ABajaProblemas de red momentáneos
BulkheadAislamiento de recursosN/AMediaServicios de criticidad mixta
Saga (coreografía)Rollback vía eventosEventualAltaServicios débilmente acoplados
Saga (orquestación)Coordinador centralEventualAltaFlujos de trabajo complejos

Lo que funciona

  • Establece budgets de timeout apropiados: cada llamada saliente debería tener un timeout más corto que el timeout del llamador. Si tu API tiene un SLA de 2 segundos, las llamadas downstream deberían timeoutear a los 500ms para dejar margen para retries y fallbacks.
  • Implementa degradación graceful: cuando un servicio no está disponible, retorna datos cacheados, valores por defecto o funcionalidad reducida en lugar de fallar completamente. Una página de producto sin recomendaciones es mejor que un error 500.
  • Monitorea el estado del circuit breaker: expone estados de breakers (closed/open/half-open) como métricas. Alerta cuando los breakers se abren frecuentemente — esto indica problemas sistémicos, no solo fallos transitorios.
  • Idempotencia para retries: los retries pueden causar operaciones duplicadas. Consulta Endpoints Idempotentes para patrones de deduplicación. Sin idempotencia, los retries crean datos inconsistentes.
  • Testea inyección de fallos: Si tus patrones de resiliencia solo funcionan en teoría, fallarán en producción.

Errores comunes

  • Reintentar en todos los errores: un 404 Not Found o 401 Unauthorized no tendrá éxito al reintentar. Solo reintenta operaciones idempotentes que fallen con 5xx, timeouts o errores de red.
  • Loops infinitos de retry: sin un máximo de reintentos o timeout, una dependencia fallida puede crear un loop infinito de retries, consumiendo threads y memoria. Siempre limita retries a 3-5 intentos con un budget total bajo 30 segundos.
  • Ignorar pools de threads: los retries bloqueantes consumen threads. En runtimes async (Node. js, Go), esto agota el event loop.
  • Faltar compensaciones en sagas: una saga sin compensación es solo una secuencia de requests esperanzados. Si el paso 3 falla pero los pasos 1-2 no tienen rollback, el sistema queda en un estado inconsistente. Cada paso de saga debe tener una compensación testeada.

Preguntas frecuentes

Patrón Bulkhead (Go / errgroup con semáforo)
package main

import (
	"context"
	"fmt"
	"sync"
	"time"

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

type Bulkhead struct {
	sem chan struct{}
}

func NewBulkhead(maxConcurrent int) *Bulkhead {
	return &Bulkhead{sem: make(chan struct{}, maxConcurrent)}
}

func (b *Bulkhead) Execute(ctx context.Context, fn func() error) error {
	select {
	case b.sem <- struct{}{}:
		defer func() { <-b.sem }()
		return fn()
	case <-ctx.Done():
		return ctx.Err()
	}
}

type ResilientClient struct {
	paymentBulkhead    *Bulkhead
	inventoryBulkhead  *Bulkhead
	shippingBulkhead   *Bulkhead
}

func NewResilientClient() *ResilientClient {
	return &ResilientClient{
		paymentBulkhead:   NewBulkhead(10),
		inventoryBulkhead: NewBulkhead(20),
		shippingBulkhead:  NewBulkhead(5),
	}
}

func (c *ResilientClient) ProcessOrder(ctx context.Context, order Order) error {
	g, ctx := errgroup.WithContext(ctx)

	g.Go(func() error {
		return c.paymentBulkhead.Execute(ctx, func() error {
			return c.callPaymentService(ctx, order)
		})
	})

	g.Go(func() error {
		return c.inventoryBulkhead.Execute(ctx, func() error {
			return c.callInventoryService(ctx, order)
		})
	})

	if err := g.Wait(); err != nil {
		return fmt.Errorf("order processing failed: %w", err)
	}

	return c.shippingBulkhead.Execute(ctx, func() error {
		return c.callShippingService(ctx, order)
	})
}

func (c *ResilientClient) callPaymentService(ctx context.Context, order Order) error {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()
	// Llamada HTTP al servicio de pagos
	return nil
}

func (c *ResilientClient) callInventoryService(ctx context.Context, order Order) error {
	ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
	defer cancel()
	// Llamada HTTP al servicio de inventario
	return nil
}

func (c *ResilientClient) callShippingService(ctx context.Context, order Order) error {
	ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
	defer cancel()
	// Llamada HTTP al servicio de envíos
	return nil
}
Resiliencia Combinada con Java Resilience4j
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryConfig;
import io.github.resilience4j.bulkhead.Bulkhead;
import io.github.resilience4j.bulkhead.BulkheadConfig;
import io.github.resilience4j.timelimiter.TimeLimiter;
import io.vavr.control.Try;

import java.time.Duration;
import java.util.concurrent.*;

public class ResilientPaymentClient {

    private final CircuitBreaker circuitBreaker;
    private final Retry retry;
    private final Bulkhead bulkhead;
    private final TimeLimiter timeLimiter;
    private final ExecutorService executor;

    public ResilientPaymentClient() {
        // Circuit breaker: abre con 50% de tasa de fallo y mínimo 10 llamadas
        CircuitBreakerConfig cbConfig = CircuitBreakerConfig.custom()
            .failureRateThreshold(50)
            .slowCallRateThreshold(50)
            .slowCallDurationThreshold(Duration.ofSeconds(2))
            .minimumNumberOfCalls(10)
            .waitDurationInOpenState(Duration.ofSeconds(30))
            .permittedNumberOfCallsInHalfOpenState(3)
            .build();
        this.circuitBreaker = CircuitBreaker.of("payment", cbConfig);

        // Retry: 3 intentos con backoff exponencial
        RetryConfig retryConfig = RetryConfig.custom()
            .maxAttempts(3)
            .waitDuration(Duration.ofMillis(500))
            .intervalFunction(attempt -> Math.pow(2, attempt) * 500)
            .retryOnException(e -> e instanceof TimeoutException || e instanceof ConnectionException)
            .build();
        this.retry = Retry.of("payment", retryConfig);

        // Bulkhead: máximo 10 llamadas concurrentes
        BulkheadConfig bulkheadConfig = BulkheadConfig.custom()
            .maxConcurrentCalls(10)
            .maxWaitDuration(Duration.ofMillis(500))
            .build();
        this.bulkhead = Bulkhead.of("payment", bulkheadConfig);

        // Time limiter: máximo 3 segundos
        this.timeLimiter = TimeLimiter.of(Duration.ofSeconds(3));
        this.executor = Executors.newCachedThreadPool();
    }

    public PaymentResult charge(String orderId, double amount) {
        Supplier<PaymentResult> supplier = () -> callPaymentService(orderId, amount);

        Supplier<PaymentResult> decorated = Decorators.ofSupplier(supplier)
            .withCircuitBreaker(circuitBreaker)
            .withRetry(retry)
            .withBulkhead(bulkhead)
            .decorate();

        return Try.ofSupplier(decorated)
            .recover(throwable -> {
                // Fallback: retornar resultado cacheado o por defecto
                return PaymentResult.degraded(orderId, "Payment unavailable, using fallback");
            })
            .get();
    }

    private PaymentResult callPaymentService(String orderId, double amount) {
        // Llamada HTTP real al servicio de pagos
        // Lanza excepción en caso de fallo
        return new PaymentResult(orderId, "SUCCESS");
    }
}
Health Probes de Kubernetes para Resiliencia
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      containers:
        - name: order-service
          image: myregistry/order-service:latest
          ports:
            - containerPort: 8080
          # Liveness probe: reiniciar contenedor si no está saludable
          livenessProbe:
            httpGet:
              path: /health/live
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 10
            failureThreshold: 3
          # Readiness probe: remover del servicio si no está listo
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 5
            failureThreshold: 2
          # Startup probe: esperar a que la app arranque
          startupProbe:
            httpGet:
              path: /health/startup
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
            failureThreshold: 30
          resources:
            limits:
              cpu: "500m"
              memory: "512Mi"
            requests:
              cpu: "250m"
              memory: "256Mi"
// Endpoints de health check para probes de Kubernetes
import express from 'express';

const app = express();

// Liveness: ¿el proceso está corriendo?
app.get('/health/live', (req, res) => {
  res.status(200).json({ status: 'alive' });
});

// Readiness: ¿podemos manejar tráfico?
app.get('/health/ready', async (req, res) => {
  const checks = await Promise.allSettled([
    checkDatabaseConnection(),
    checkRedisConnection(),
    checkDownstreamServices(),
  ]);

  const allHealthy = checks.every(c => c.status === 'fulfilled');
  if (allHealthy) {
    res.status(200).json({ status: 'ready' });
  } else {
    res.status(503).json({
      status: 'not ready',
      failures: checks
        .filter(c => c.status === 'rejected')
        .map(c => c.reason.message),
    });
  }
});

// Startup: ¿la app terminó de inicializar?
app.get('/health/startup', (req, res) => {
  if (app.locals.initialized) {
    res.status(200).json({ status: 'started' });
  } else {
    res.status(503).json({ status: 'starting' });
  }
});