StackPractices
intermediate Por Mathias Paulenko

Patrón Circuit Breaker en Python, JavaScript y Java

Previene fallos en cascada deteniendo solicitudes a servicios que están fallando. Un patrón arquitectural para sistemas distribuidos resilientes.

Temas: design

Visión General

El patrón Circuit Breaker envuelve las llamadas a una dependencia remota para que tu aplicación deje de martillear un servicio que ya está fallando. Cuando los fallos superan un umbral, el breaker “salta” y las llamadas fallan rápido en lugar de agotar el timeout una a una. Tras un enfriamiento, unas pocas llamadas de prueba deciden si la dependencia se recuperó.

Esto previene dos cosas a la vez: fallos en cascada a través de tus propios servicios, y agotamiento de recursos por hilos apilados en una dependencia muerta — un modo de fallo clásico en sistemas distribuidos.

Cuándo Usarlo

Usa un circuit breaker cuando:

  • Una llamada a un servicio puede fallar o colgarse y no puedes permitirte bloquear en cada intento
  • Necesitas degradación elegante: datos en caché, una respuesta por defecto, o un reintento diferido
  • Quieres darle a una dependencia saturada espacio para recuperarse en lugar de ahogarla en reintentos

Sáltalo cuando la llamada es in-process (sin frontera de red), cuando los fallos deben llegar al llamante inmediatamente, o cuando la dependencia es tan crítica que cada petición la necesita legítimamente — en ese caso quieres timeouts más retry, y un circuit breaker encima solo si el downstream puede saturarse.

Solución

Python

import time
from enum import Enum, auto

class CircuitState(Enum):
    CLOSED = auto()      # Operación normal
    OPEN = auto()        # Fallo rápido
    HALF_OPEN = auto()   # Probando recuperación

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

    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
            else:
                raise Exception("Circuit breaker está OPEN")

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

    def _on_success(self):
        self.failures = 0
        self.state = CircuitState.CLOSED

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

# Uso
def fetch_data():
    import random
    if random.random() < 0.7:
        raise Exception("Error de servicio")
    return "Datos"

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

for i in range(5):
    try:
        print(breaker.call(fetch_data))
    except Exception as e:
        print(f"Llamada {i+1}: {e}")

JavaScript

class CircuitBreaker {
  constructor(failureThreshold = 3, recoveryTimeout = 5000) {
    this.failureThreshold = failureThreshold;
    this.recoveryTimeout = recoveryTimeout;
    this.state = 'CLOSED';
    this.failures = 0;
    this.lastFailureTime = null;
  }

  async call(fn) {
    if (this.state === 'OPEN') {
      if (Date.now() - this.lastFailureTime >= this.recoveryTimeout) {
        this.state = 'HALF_OPEN';
      } else {
        throw new Error('Circuit breaker está OPEN');
      }
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (e) {
      this.onFailure();
      throw e;
    }
  }

  onSuccess() {
    this.failures = 0;
    this.state = 'CLOSED';
  }

  onFailure() {
    this.failures++;
    this.lastFailureTime = Date.now();
    if (this.failures >= this.failureThreshold) {
      this.state = 'OPEN';
    }
  }
}

// Uso
async function fetchData() {
  if (Math.random() < 0.7) throw new Error('Error de servicio');
  return 'Datos';
}

const breaker = new CircuitBreaker(2, 3000);

(async () => {
  for (let i = 0; i < 5; i++) {
    try {
      const result = await breaker.call(fetchData);
      console.log(`Llamada ${i + 1}:`, result);
    } catch (e) {
      console.log(`Llamada ${i + 1}:`, e.message);
    }
  }
})();

Java

import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;

public class CircuitBreaker {
    private enum State { CLOSED, OPEN, HALF_OPEN }

    private final int failureThreshold;
    private final long recoveryTimeoutMs;
    private State state = State.CLOSED;
    private final AtomicInteger failures = new AtomicInteger(0);
    private volatile long lastFailureTime = 0;

    public CircuitBreaker(int failureThreshold, long recoveryTimeoutMs) {
        this.failureThreshold = failureThreshold;
        this.recoveryTimeoutMs = recoveryTimeoutMs;
    }

    public <T> T call(Supplier<T> supplier) throws Exception {
        if (state == State.OPEN) {
            if (System.currentTimeMillis() - lastFailureTime >= recoveryTimeoutMs) {
                state = State.HALF_OPEN;
            } else {
                throw new Exception("Circuit breaker está OPEN");
            }
        }

        try {
            T result = supplier.get();
            onSuccess();
            return result;
        } catch (Exception e) {
            onFailure();
            throw e;
        }
    }

    private void onSuccess() {
        failures.set(0);
        state = State.CLOSED;
    }

    private void onFailure() {
        int count = failures.incrementAndGet();
        lastFailureTime = System.currentTimeMillis();
        if (count >= failureThreshold) {
            state = State.OPEN;
        }
    }
}

// Uso
public class Main {
    public static void main(String[] args) {
        CircuitBreaker breaker = new CircuitBreaker(2, 3000);

        for (int i = 0; i < 5; i++) {
            try {
                String result = breaker.call(() -> {
                    if (Math.random() < 0.7) throw new RuntimeException("Error de servicio");
                    return "Datos";
                });
                System.out.println("Llamada " + (i + 1) + ": " + result);
            } catch (Exception e) {
                System.out.println("Llamada " + (i + 1) + ": " + e.getMessage());
            }
        }
    }
}

Explicación

El breaker es una máquina de estados con tres estados:

flowchart diagram: CLOSED[
  • Cerrado (Closed): las llamadas pasan normalmente y los fallos se cuentan. Un éxito reinicia el contador.
  • Abierto (Open): cada llamada falla rápido sin tocar la dependencia — el llamante recibe un error “circuit open” en milisegundos en lugar de un timeout de 30 s. Lo que ve después depende de tu fallback: una respuesta cacheada/por defecto si la proporcionaste, o una excepción que manejar. El contador se congela y arranca un temporizador.
  • Semi-abierto (Half-open): cuando vence el timeout de recuperación, un número limitado de llamadas de prueba pasa. Un éxito cierra el circuito; un fallo lo reabre y reinicia el temporizador.

Dos detalles importan más que el diagrama de estados. Primero, qué cuenta como fallo: un 404 o un error de validación normalmente no debería disparar el breaker, mientras que un timeout o un connection reset sí. Los ejemplos de arriba cuentan toda excepción por simplicidad — los breakers de producción clasifican. Segundo, concurrencia: la versión Java usa AtomicInteger porque varios hilos golpean call() a la vez; un contador ingenuo cuenta de menos bajo contención y el breaker salta tarde. Lo mismo aplica en Node.js cuando las llamadas corren concurrentes, aunque el event loop hace seguro al contador en sí.

El patrón funciona en cualquier frontera donde una llamada puede colgarse o fallar: clientes HTTP, drivers de base de datos, llamadas al sistema de archivos. El ejemplo Java es síncrono (Supplier<T>); el de JavaScript es async porque las llamadas de red ahí suelen serlo.

Variantes

VarianteComportamientoCaso de Uso
Basado en conteoSalta tras N fallos consecutivosComportamiento simple y predecible
Basado en tiempoSalta si la tasa de fallos supera un umbral en una ventana deslizanteSe adapta a carga variable
PonderadoUmbrales distintos por tipo de excepciónDistingue fallos transitorios de permanentes
Fallback personalizadoDevuelve un valor cacheado o por defecto cuando está abierto, en lugar de lanzarDegradación elegante (caché, respuesta por defecto)

Librerías de Producción

Implementar el tuyo sirve para aprender, pero los breakers de producción también necesitan métricas, ventanas deslizantes y permisos half-open. Estas son las opciones estándar:

  • Java: Resilience4j con anotaciones @CircuitBreaker o la API de decoradores funcionales.
  • Python: pybreaker — breaker = CircuitBreaker(fail_max=5, reset_timeout=60) envuelve cualquier callable.
  • JavaScript: opossum — breaker basado en promesas con hook fallback() integrado.
  • .NET: CircuitBreakerPolicy de Polly, el estándar de facto del ecosistema.

Las cuatro emiten eventos de cambio de estado que puedes enviar a Prometheus o a tu APM, que es la razón principal para preferirlas sobre una versión casera.

Lo que funciona

  • Configura timeouts de recuperación según el tiempo típico de reinicio del servicio
  • Registra transiciones de estado para observabilidad y alertas
  • Proporciona comportamiento de fallback cuando el circuito está abierto (datos cacheados, respuesta por defecto)
  • Usa circuit breakers separados para diferentes servicios downstream
  • Evita compartir estado del breaker entre operaciones no relacionadas

Errores Comunes

  • Configurar el umbral de fallo demasiado bajo, causando falsos positivos frecuentes
  • Configurar el timeout de recuperación demasiado corto, sin dar tiempo al servicio a recuperarse
  • Usar un solo circuit breaker para todas las operaciones, causando cortes innecesariamente amplios
  • No proporcionar comportamiento de fallback, llevando a mala experiencia de usuario cuando el circuito está abierto
  • Ignorar el estado semi-abierto, nunca permitiendo pruebas de recuperación tras un fallo

Troubleshooting

  • El breaker salta constantemente con carga normal: el umbral de fallo es demasiado bajo, o errores de negocio (4xx, excepciones de validación) se están contando como fallos. Clasifica las excepciones para que solo los fallos de infraestructura incrementen el contador.
  • El breaker nunca se recupera aunque el servicio esté sano: el timeout de recuperación es más largo que la duración del incidente, o cada prueba en half-open vuelve a saltar porque la dependencia aún se está calentando. Dale a las pruebas su propio timeout y considera exigir dos éxitos consecutivos antes de cerrar.
  • El fallback llama a la misma dependencia fallida: un fallback que golpea el mismo downstream por el mismo pool de conexiones anula el propósito. Sirve datos cacheados o un valor estático, o enruta el fallback a otro cluster.
  • Las peticiones siguen agotando el timeout con el breaker abierto: fallo rápido significa lanzar inmediatamente — si tu wrapper sigue esperando la llamada, el breaker solo está contando, no protegiendo. Asegúrate de que el estado abierto cortocircuita antes de emitir la petición.
  • Una dependencia lenta abre circuitos de servicios no relacionados: compartiste un breaker (o su contador) entre endpoints. Acota los breakers por dependencia, no por aplicación.

Ver también

Preguntas frecuentes

¿En qué se diferencia circuit breaker de retry?

Retry intenta la misma operación varias veces y asume que el siguiente intento puede tener éxito. El circuit breaker asume que llamadas repetidas empeoran las cosas y deja de llamar por completo. Funcionan juntos: retry dentro de la ventana para fallos transitorios, breaker para cortes persistentes.

¿Debería usar una librería o implementar la mía?

En producción, usa una librería — Resilience4j (Java), pybreaker (Python), opossum (Node), Polly (.NET). Obtienes métricas de ventana deslizante y permisos half-open gratis. Implementa la tuya solo para aprender la mecánica o en entornos muy restringidos. Consulta también Ambassador para delegar la lógica de resiliencia a un sidecar.

¿Cómo monitoreo la salud del circuit breaker?

Expón transiciones de estado, tasa de fallos y tiempo en abierto como métricas; alerta sobre saltos frecuentes o un breaker que se queda abierto. Resilience4j y opossum emiten estos eventos de forma nativa — con un breaker casero, registra cada transición como mínimo.