Patrón Circuit Breaker Half-Open
Cómo probar la recuperación de servicios con el estado half-open del circuit breaker. Cubre closed, open, half-open, requests de prueba y recuperación gradual.
Overview
Un circuit breaker que salta de open a closed directamente tiene un problema: la primera ráfaga de tráfico golpea un servicio que quizás todavía no está listo. El estado half-open lo resuelve dejando pasar primero unos pocos requests de prueba. Si tienen éxito, el circuito se cierra y el tráfico vuelve. Si fallan, se reabre y el servicio gana más tiempo para recuperarse. Es el puente entre “algo anda mal” y “estamos seguros de que se arregló”, y combina bien con retry con jitter para las llamadas que sí pasan.
When to Use
- Proteger servicios de fallos en cascada durante cortes
- Llamar a servicios downstream que pueden quedar temporalmente no disponibles
- Microservicios donde el fallo de un servicio no debería cascadear a todos los callers
- Gestión de conexiones a base de datos con recuperación automática
- Llamadas a APIs externas que pueden tener cortes transitorios
When NOT to Use
- Llamadas a funciones dentro del mismo proceso (usa try/catch)
- Operaciones donde el fallo es permanente (errores de validación, 404s)
- Sistemas de tiempo real donde la latencia de rechazo del estado open es inaceptable
- Cuando necesitas que cada request llegue al servicio (sin fail-fast)
Solution
Circuit breaker de tres estados (Python)
# circuit_breaker/three_state.py — Circuit breaker con estado half-open
import time
import threading
from enum import Enum
class CircuitState(Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
class CircuitBreaker:
"""Circuit breaker con estados closed, open y half-open.
Closed: los requests fluyen normal. Los fallos incrementan el contador.
Open: los requests se rechazan de inmediato. Tras el timeout, pasa a half-open.
Half-open: requests de prueba limitados. El éxito cierra, el fallo reabre.
"""
def __init__(self, failure_threshold=5, recovery_timeout=30,
half_open_max_calls=3, success_threshold=2):
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.half_open_max_calls = half_open_max_calls
self.success_threshold = success_threshold
self._state = CircuitState.CLOSED
self._failure_count = 0
self._success_count = 0
self._half_open_calls = 0
self._last_failure_time = 0
self._lock = threading.Lock()
@property
def state(self):
with self._lock:
self._check_state_transition()
return self._state
def _check_state_transition(self):
"""Verifica si el circuito debería pasar de open a half-open."""
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
self._success_count = 0
print("Circuit transitioned: OPEN -> HALF_OPEN")
def call(self, fn, *args, **kwargs):
"""Ejecuta fn a través del circuit breaker."""
with self._lock:
self._check_state_transition()
if self._state == CircuitState.OPEN:
raise CircuitBreakerOpenError(
"Circuit is open: service unavailable"
)
if self._state == CircuitState.HALF_OPEN:
if self._half_open_calls >= self.half_open_max_calls:
raise CircuitBreakerOpenError(
"Circuit is half-open: trial request limit reached"
)
self._half_open_calls += 1
# Ejecuta la función fuera del lock
try:
result = fn(*args, **kwargs)
self._on_success()
return result
except Exception:
self._on_failure()
raise
def _on_success(self):
with self._lock:
if self._state == CircuitState.HALF_OPEN:
self._success_count += 1
if self._success_count >= self.success_threshold:
self._state = CircuitState.CLOSED
self._failure_count = 0
print("Circuit transitioned: HALF_OPEN -> CLOSED")
elif self._state == CircuitState.CLOSED:
self._failure_count = 0
def _on_failure(self):
with self._lock:
self._last_failure_time = time.time()
if self._state == CircuitState.HALF_OPEN:
self._state = CircuitState.OPEN
self._failure_count = 0
print("Circuit transitioned: HALF_OPEN -> OPEN")
elif self._state == CircuitState.CLOSED:
self._failure_count += 1
if self._failure_count >= self.failure_threshold:
self._state = CircuitState.OPEN
print("Circuit transitioned: CLOSED -> OPEN "
f"(failures: {self._failure_count})")
def get_state(self):
with self._lock:
self._check_state_transition()
return {
"state": self._state.value,
"failure_count": self._failure_count,
"success_count": self._success_count,
"half_open_calls": self._half_open_calls
}
def reset(self):
with self._lock:
self._state = CircuitState.CLOSED
self._failure_count = 0
self._success_count = 0
self._half_open_calls = 0
class CircuitBreakerOpenError(Exception):
"""Se lanza cuando el circuito está open y los requests se rechazan."""
pass
# Uso
breaker = CircuitBreaker(
failure_threshold=5,
recovery_timeout=30,
half_open_max_calls=3,
success_threshold=2
)
def call_payment_service(order_id):
return breaker.call(lambda: payment_api.charge(order_id))
Registry de circuit breakers por servicio (Python)
# circuit_breaker/registry.py — Gestiona circuit breakers por servicio
import threading
from circuit_breaker.three_state import CircuitBreaker, CircuitState
class CircuitBreakerRegistry:
"""Mantiene un circuit breaker por nombre de servicio.
Cada servicio tiene estado y umbrales independientes."""
def __init__(self):
self._breakers = {}
self._lock = threading.Lock()
def get_or_create(self, service_name, failure_threshold=5,
recovery_timeout=30, half_open_max_calls=3,
success_threshold=2):
with self._lock:
if service_name not in self._breakers:
self._breakers[service_name] = CircuitBreaker(
failure_threshold=failure_threshold,
recovery_timeout=recovery_timeout,
half_open_max_calls=half_open_max_calls,
success_threshold=success_threshold
)
return self._breakers[service_name]
def call(self, service_name, fn, *args, **kwargs):
breaker = self.get_or_create(service_name)
return breaker.call(fn, *args, **kwargs)
def get_all_states(self):
with self._lock:
return {
name: breaker.get_state()
for name, breaker in self._breakers.items()
}
def reset_all(self):
with self._lock:
for breaker in self._breakers.values():
breaker.reset()
# Uso
registry = CircuitBreakerRegistry()
def call_payment(order_id):
return registry.call("payment", payment_api.charge, order_id)
def call_inventory(product_id):
return registry.call("inventory", inventory_api.check, product_id)
# Monitorear todos los circuitos
for name, state in registry.get_all_states().items():
print(f"{name}: {state['state']}")
Circuit breaker en Java con resilience4j
// CircuitBreakerConfig.java — Circuit breaker en Java con resilience4j
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import java.time.Duration;
public class ServiceCircuitBreakers {
private final CircuitBreakerRegistry registry;
public ServiceCircuitBreakers() {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 50% de fallos abre el circuito
.slowCallRateThreshold(80) // 80% de llamadas lentas abre el circuito
.slowCallDurationThreshold(Duration.ofSeconds(5))
.waitDurationInOpenState(Duration.ofSeconds(30))
.slidingWindowSize(10) // Últimas 10 llamadas
.minimumNumberOfCalls(5) // Mínimo 5 llamadas antes de evaluar
.permittedNumberOfCallsInHalfOpenState(3) // Llamadas de prueba en half-open
.waitDurationInHalfOpenState(Duration.ofSeconds(10))
.automaticTransitionFromOpenToHalfOpenEnabled(true)
.build();
this.registry = CircuitBreakerRegistry.of(config);
}
public <T> T callWithBreaker(String serviceName, java.util.function.Supplier<T> supplier) {
CircuitBreaker breaker = registry.circuitBreaker(serviceName);
return breaker.executeSupplier(supplier);
}
public String callPayment(String orderId) {
return callWithBreaker("payment", () -> paymentClient.charge(orderId));
}
public String callInventory(String productId) {
return callWithBreaker("inventory", () -> inventoryClient.checkStock(productId));
}
public void printStates() {
registry.getAllCircuitBreakers().forEach(breaker -> {
var state = breaker.getState();
var metrics = breaker.getMetrics();
System.out.printf("%s: state=%s, failures=%d, success=%d%n",
breaker.getName(),
state,
metrics.getNumberOfFailedCalls(),
metrics.getNumberOfSuccessfulCalls());
});
}
}
Circuit breaker con métricas (Python)
# circuit_breaker/with_metrics.py — Circuit breaker con métricas Prometheus
import time
from prometheus_client import Counter, Gauge, Histogram
from circuit_breaker.three_state import (
CircuitBreaker, CircuitState, CircuitBreakerOpenError
)
class InstrumentedCircuitBreaker(CircuitBreaker):
"""Circuit breaker que exporta métricas a Prometheus."""
_state_gauges = {}
_call_counters = {}
_call_latency = {}
def __init__(self, name, **kwargs):
super().__init__(**kwargs)
self.name = name
if name not in self._state_gauges:
self._state_gauges[name] = Gauge(
f"circuit_breaker_state_{name}",
f"Circuit breaker state for {name} (0=closed, 1=open, 2=half_open)"
)
self._call_counters[name] = Counter(
f"circuit_breaker_calls_{name}",
f"Calls through circuit breaker {name}",
["result"]
)
self._call_latency[name] = Histogram(
f"circuit_breaker_latency_{name}",
f"Call latency through circuit breaker {name}"
)
def call(self, fn, *args, **kwargs):
state_map = {
CircuitState.CLOSED: 0,
CircuitState.OPEN: 1,
CircuitState.HALF_OPEN: 2
}
self._state_gauges[self.name].set(state_map.get(self.state, 0))
try:
start = time.time()
result = super().call(fn, *args, **kwargs)
latency = time.time() - start
self._call_counters[self.name].labels(result="success").inc()
self._call_latency[self.name].observe(latency)
return result
except CircuitBreakerOpenError:
self._call_counters[self.name].labels(result="rejected").inc()
raise
except Exception:
self._call_counters[self.name].labels(result="failure").inc()
raise
Circuit breaker en JavaScript
// circuit_breaker/index.js — Circuit breaker para Node.js
class CircuitBreaker {
constructor(options = {}) {
this.failureThreshold = options.failureThreshold || 5;
this.recoveryTimeout = options.recoveryTimeout || 30000;
this.halfOpenMaxCalls = options.halfOpenMaxCalls || 3;
this.successThreshold = options.successThreshold || 2;
this.state = "closed";
this.failureCount = 0;
this.successCount = 0;
this.halfOpenCalls = 0;
this.lastFailureTime = 0;
}
async call(fn, ...args) {
this._checkStateTransition();
if (this.state === "open") {
throw new Error("Circuit is open: service unavailable");
}
if (this.state === "half-open") {
if (this.halfOpenCalls >= this.halfOpenMaxCalls) {
throw new Error("Circuit is half-open: trial limit reached");
}
this.halfOpenCalls++;
}
try {
const result = await fn(...args);
this._onSuccess();
return result;
} catch (error) {
this._onFailure();
throw error;
}
}
_checkStateTransition() {
if (this.state === "open") {
if (Date.now() - this.lastFailureTime >= this.recoveryTimeout) {
this.state = "half-open";
this.halfOpenCalls = 0;
this.successCount = 0;
console.log("Circuit: OPEN -> HALF_OPEN");
}
}
}
_onSuccess() {
if (this.state === "half-open") {
this.successCount++;
if (this.successCount >= this.successThreshold) {
this.state = "closed";
this.failureCount = 0;
console.log("Circuit: HALF_OPEN -> CLOSED");
}
} else if (this.state === "closed") {
this.failureCount = 0;
}
}
_onFailure() {
this.lastFailureTime = Date.now();
if (this.state === "half-open") {
this.state = "open";
console.log("Circuit: HALF_OPEN -> OPEN");
} else if (this.state === "closed") {
this.failureCount++;
if (this.failureCount >= this.failureThreshold) {
this.state = "open";
console.log(`Circuit: CLOSED -> OPEN (${this.failureCount} failures)`);
}
}
}
getState() {
return {
state: this.state,
failureCount: this.failureCount,
successCount: this.successCount,
halfOpenCalls: this.halfOpenCalls
};
}
}
// Uso
const breaker = new CircuitBreaker({
failureThreshold: 5,
recoveryTimeout: 30000,
halfOpenMaxCalls: 3,
successThreshold: 2
});
async function callApi(url) {
return breaker.call(async (u) => {
const response = await fetch(u);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}, url);
}
Explanation
La máquina de estados es todo el patrón:
Dos detalles de diseño pesan más que el resto. Primero, la transición de open a half-open es perezosa: ocurre dentro de call(), cuando llega un request después del timeout, no en un timer. Así, un servicio en silencio nunca quema requests de prueba hasta que vuelve el tráfico; y también significa que la inspección de estado (get_state()) necesita el mismo chequeo de transición, por eso la property lo envuelve. Segundo, en half-open un solo fallo reabre el circuito de inmediato mientras que cerrar exige success_threshold éxitos consecutivos. Esa asimetría es deliberada: fallar rápido es barato, pero declarar la recuperación demasiado pronto reinicia el corte.
Ojo con un caso sutil en la variante gradual: una llamada que llega mientras el circuito está open puede pasarlo a half-open durante super().call(), así que cualquier contabilidad de fases tiene que releer el estado después de la llamada en vez de confiar en el que había al entrar.
La versión en Python mantiene un threading.Lock alrededor de los chequeos de estado pero ejecuta la llamada real fuera de él, porque bloquear el lock en un servicio lento serializaría cada request. Para una guía más profunda sobre servir respuestas degradadas mientras el circuito está abierto, mira el patrón graceful degradation.
Variants
Recuperación gradual (half-open escalonado)
# circuit_breaker/gradual.py — Aumenta el tráfico gradualmente en half-open
class GradualCircuitBreaker(CircuitBreaker):
"""En vez de closed/half-open binario, aumenta el tráfico permitido gradualmente.
Half-open fase 1: 10% de los requests
Half-open fase 2: 50% de los requests
Half-open fase 3: 100% (closed)"""
PHASES = [
{"name": "phase1", "ratio": 0.1, "success_needed": 5},
{"name": "phase2", "ratio": 0.5, "success_needed": 10},
{"name": "phase3", "ratio": 1.0, "success_needed": 20},
]
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._phase = 0
self._phase_success = 0
def call(self, fn, *args, **kwargs):
import random
if self.state == CircuitState.HALF_OPEN:
phase = self.PHASES[self._phase]
if random.random() > phase["ratio"]:
raise CircuitBreakerOpenError(
f"Half-open {phase['name']}: only {phase['ratio']*100}% traffic"
)
try:
result = super().call(fn, *args, **kwargs)
# Relee el estado: super().call() puede haber pasado OPEN -> HALF_OPEN
if self.state == CircuitState.HALF_OPEN:
phase = self.PHASES[self._phase]
self._phase_success += 1
if self._phase_success >= phase["success_needed"]:
self._phase = min(self._phase + 1, len(self.PHASES) - 1)
if self._phase == len(self.PHASES) - 1:
self._state = CircuitState.CLOSED
return result
except Exception:
self._phase = 0
self._phase_success = 0
raise
Circuit breaker con fallback
# circuit_breaker/with_fallback.py — Devuelve fallback cuando el circuito está open
class CircuitBreakerWithFallback:
"""Cuando el circuito está open, devuelve una respuesta fallback
en vez de lanzar un error."""
def __init__(self, breaker, fallback_fn):
self._breaker = breaker
self._fallback = fallback_fn
def call(self, fn, *args, **kwargs):
try:
return self._breaker.call(fn, *args, **kwargs)
except CircuitBreakerOpenError:
return self._fallback(*args, **kwargs)
# Uso
breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=30)
def fallback_product(product_id):
return {"id": product_id, "name": "Unavailable", "cached": True}
protected = CircuitBreakerWithFallback(
breaker,
fallback_fn=fallback_product
)
def get_product(product_id):
return protected.call(inventory_api.get_product, product_id)
Best Practices
- Define umbrales apropiados: demasiado bajos causan aperturas falsas; demasiado altos permiten fallos en cascada
- Usa una ventana deslizante para contar fallos, no un contador simple que nunca se reinicia
- Monitorea las transiciones de estado del circuito y alerta sobre oscilación frecuente open/close
- Exige N éxitos consecutivos en half-open antes de cerrar; un solo éxito prueba poco
- Combina con fallbacks: devuelve datos cacheados o por defecto mientras el circuito está abierto (graceful degradation cubre el lado de servir)
- Exporta métricas: registra estado, tasa de fallos y conteo de rechazos por circuito
- Prueba el breaker en sí: inyecta fallos y verifica cada transición de estado
Common Mistakes
- Sin estado half-open: el circuito oscila entre open y closed, inundando el servicio en recuperación.
- Umbral de fallos demasiado bajo: el circuito abre con un solo fallo, rechazando tráfico válido.
- Sin umbral de éxito: el primer éxito en half-open cierra el circuito, pero el servicio puede no estar del todo recuperado.
- No reiniciar el contador de fallos tras un éxito: una vez que los fallos se acumulan, el circuito nunca cierra.
- Sin monitoreo: no sabes que los circuitos están abriendo hasta que los usuarios se quejan.
- Mantener el lock durante la llamada: una llamada lenta al downstream bloquea todos los demás requests detrás del lock de estado. Chequea el estado bajo el lock, llama fuera de él.
Preguntas frecuentes
¿Qué es el estado half-open en un circuit breaker?
Un estado de transición entre open y closed donde el circuito deja pasar un número limitado de requests de prueba. Si tienen éxito se cierra y el tráfico completo vuelve; si fallan se reabre. Esto evita inundar con tráfico completo un servicio en recuperación.
¿Cuántos requests de prueba debería permitir en half-open?
Típicamente 3-5. Con muy pocos, un solo fallo de prueba lo reabre; con demasiados, arriesgas saturar el servicio mientras se recupera. Exige 2-3 éxitos consecutivos antes de cerrar el circuito.
¿Cuál es la diferencia entre umbrales de failure rate y failure count?
Failure count: el circuito abre tras N fallos sin importar el total de llamadas. Failure rate: el circuito abre cuando X% de las últimas N llamadas fallaron. El rate se adapta mejor: 5 fallos de 100 llamadas no es problema; 5 de 5 sí lo es.
¿El timeout del circuit breaker debería ser fijo o exponencial?
Un timeout fijo (30-60 segundos) es un buen punto de partida. Si el servicio se recupera rápido, el circuito abre innecesariamente. Si se recupera lento, los requests de prueba fallan y reabren el circuito. Para servicios con recuperación impredecible, usa backoff exponencial en el timeout.
¿Puedo usar circuit breaker con async/await?
Sí. Solo el registro de estado es síncrono; la función envuelta sí puede ser async. En JavaScript, envuelve la llamada async en breaker.call(asyncFn). En Python, llama breaker.call(asyncio.run, async_fn), o usa asyncio.wait_for por llamada cuando además necesitas un timeout.
Recursos Relacionados
Patrón Bulkhead: Aísla Recursos y Limita el Radio de Impacto
Cómo aislar recursos por servicio para limitar el radio de impacto. Cubre aislamiento de thread pools, pools de conexión, bulkheads con semáforos y cuotas de recursos.
PatternPatrón Retry with Jitter
Cómo retryar failed operations con exponential backoff y random jitter. Cubre full jitter, equal jitter, decorrelated jitter, retry budgets, e idempotency.
PatternPatrón Graceful Shutdown: Drenar Peticiones antes de Salir
Cómo drenar las peticiones en curso antes de que el proceso salga: señales, health check, draining de conexiones y timeouts.
PatternPatrón Failover: Conmuta a Standby ante Fallo del Primario
Cómo desviar el tráfico a un sistema standby cuando el primario falla. Cubre active-passive, active-active, health checks, failover DNS y promoción de réplicas.
PatternPatrón Token Bucket: Rate Limiting con Ráfagas Controladas
Cómo implementar rate limiting con token bucket para protección de APIs — refill del bucket, manejo de ráfagas, cuotas por clave y límites distribuidos con Redis.