La Guía Definitiva de Circuit Breakers en Spring Boot (Java)
Un libro de consulta completo para la resiliencia de sistemas distribuidos, optimizado para ingeniería.
1. Introducción a la Resiliencia en Sistemas Distribuidos
En las arquitecturas modernas de microservicios, los sistemas son de naturaleza distribuida. Las aplicaciones realizan llamadas remotas a través de la red a servidores de bases de datos, proveedores de SaaS externos y otros microservicios internos. Aunque la distribución por red ofrece escalabilidad y desacoplamiento, introduce un punto de fallo crítico: el fallo parcial.
Un sistema remoto puede fallar, sobrecargarse, experimentar alta latencia o sufrir microcortes de red transitorios. Sin las salvaguardas adecuadas, un fallo en una única dependencia aguas abajo (downstream) puede propagarse en cascada por todo el clúster:
Fallos en Cascada y Agotamiento del Pool de Hilos (Thread Pool Exhaustion)
Cuando un servicio aguas abajo es lento o no responde, los hilos del cliente que esperan una respuesta permanecen bloqueados. A medida que siguen llegando nuevas solicitudes, se asignan más hilos para llamar al servicio con problemas. Bajo carga, esto conduce rápidamente al Agotamiento del Pool de Hilos (Thread Pool Exhaustion) en el servicio cliente. Una vez que el pool se agota, el servicio cliente deja de responder por completo a todo el tráfico, incluso a solicitudes que no dependen del sistema remoto que está fallando.
Para construir aplicaciones tolerantes a fallos y resilientes, debemos seguir una filosofía de diseño fundamental: Diseñar para el fallo y fallar rápido (Fail Fast).
La Gran Duda: "¿Por qué usar un Circuit Breaker si la transacción igual va a fallar?"
Es la duda más común al aprender sobre este patrón: "Si el servicio remoto está caído, el pago del usuario igual no se va a procesar. ¿Por qué agregar toda esta complejidad?"
La diferencia crucial no está en si esa transacción específica funciona o no; la diferencia está en qué le pasa a todo tu sistema mientras esa pasarela está caída. Observemos la diferencia:
Escenario A: SIN Circuit Breaker (Colapso Total)
El servicio externo tarda 30 segundos en responder. El Usuario 1 intenta pagar e inmoviliza el Hilo A por 30s. El Usuario 2 inmoviliza el Hilo B. En pocos segundos, todos los hilos del servidor se agotan esperando.
Consecuencia: El Usuario 101 entra solo a ver el catálogo (que no usa pagos), pero tu página no carga y da error de conexión. El sistema entero está caído.
Escenario B: CON Circuit Breaker (Fallo Rápido)
Los primeros fallos hacen saltar el disyuntor a OPEN. Cuando el Usuario 11 intenta pagar, el disyuntor lo rechaza en 0.1 milisegundos (Fallo Rápido) liberando el hilo inmediatamente y ejecutando un fallback amigable.
Consecuencia: Los hilos nunca se bloquean. El Usuario 101 puede seguir navegando por el catálogo e iniciando sesión de manera instantánea. El fallo queda aislado.
💡 Analogía de la Vida Real: El Camarero y la Cocina
Sin Circuit Breaker: El camarero va a pedir tu plato. La cocina está colapsada. El camarero se queda parado dentro de la cocina durante 2 horas esperando tu plato. Mientras tanto, nadie atiende las otras mesas, nadie puede pedir la cuenta, nadie recibe agua. El restaurante entero se paraliza.
Con Circuit Breaker: El camarero va a la cocina, ve que está colapsada. Vuelve inmediatamente a tu mesa (tarda 10 segundos) y te dice: "La cocina está fuera de servicio temporalmente, pero podemos servirle ensaladas, bebidas y postres de inmediato". El restaurante sigue operando y ganando dinero, aislando la falla de la cocina.
2. El Patrón Circuit Breaker: Teoría Principal
El patrón Circuit Breaker (Disyuntor/Cortocircuito), popularizado por Michael Nygard en su libro de referencia Release It!, actúa como una válvula de seguridad para llamadas remotas. Envuelve las llamadas a métodos protegidos y monitoriza los fallos.
Al igual que los interruptores magnetotérmicos (fusibles) del hogar evitan que las sobrecargas eléctricas quemen una casa, los circuit breakers de software cortan la conexión con un servicio de destino que está fallando para proteger al sistema emisor.
Los Tres Estados y sus Transiciones
Un circuit breaker de software estándar existe en uno de tres estados principales:
- CLOSED (Cerrado - Estado Normal): El circuit breaker redirige todas las llamadas al servicio de destino. Monitoriza los resultados (éxitos, fallos, llamadas lentas). Si la tasa de fallos supera los umbrales configurados, pasa al estado OPEN.
- OPEN (Abierto - Fallo Rápido): El circuit breaker impide que las llamadas lleguen al servicio de destino. Lanza inmediatamente una excepción
CallNotPermittedException(falla rápido) o ejecuta un método de contingencia local (fallback). - HALF-OPEN (Semi-Abierto - Estado de Prueba): El circuit breaker permite que un número limitado y configurable de solicitudes de prueba pasen al servicio de destino. Si cualquiera falla, vuelve a OPEN. Si todas son exitosas, vuelve a CLOSED.
🌐 3 Ejemplos del Mundo Real: CLOSED, OPEN y HALF-OPEN
Para ver cómo operan estos estados en sistemas reales de producción, analizemos estos tres escenarios clásicos de arquitectura de software:
Ejemplo 1 E-Commerce (Servicio de Pedidos → Servicio de Inventario)
Cuando haces clic en "Comprar", el Servicio de Pedidos llama al Servicio de Inventario para reservar stock.
El cliente compra una camiseta. La llamada viaja al inventario, este responde "Reservado OK" en 50ms y la compra se procesa. El disyuntor registra "Éxito" en su ventana deslizante.
El inventario se apaga por mantenimiento y las llamadas dan timeout. Al cruzar el umbral del 50%, el circuito pasa a OPEN. Un nuevo cliente compra; el disyuntor bloquea la llamada en 0.1ms y ejecuta el fallback: guarda el pedido localmente y avisa *"Procesaremos el cobro cuando el sistema se recupere"*.
Pasan los 30s de espera. El disyuntor pasa a HALF-OPEN y deja pasar 5 compras de prueba. Si las 5 tienen éxito porque el inventario volvió a encender, el circuito vuelve a CLOSED. Si alguna falla, regresa a OPEN y reinicia el timer.
Ejemplo 2 App de Transporte (Tellevo → API de Google Maps)
Para cotizar un viaje de carpooling, la app llama a Google Maps para obtener la ruta y la distancia exacta.
El pasajero busca viaje. La app consulta a Google, obtiene la ruta óptima en 100ms, calcula la tarifa y presenta el viaje al instante.
La tarjeta de la empresa expira y la API responde con error de autenticación `403`. El disyuntor pasa a OPEN. Al cotizar nuevos viajes, el disyuntor bloquea las llamadas y ejecuta un fallback local: calcula la tarifa aproximada usando distancia matemática en línea recta (Fórmula de Haversine) sobre coordenadas GPS. La app sigue vendiendo viajes.
El administrador paga la cuenta de Google Maps. Pasan 60s, el disyuntor pasa a HALF-OPEN y deja pasar 5 llamadas a la API. Al responder con éxito, el disyuntor asume que la API está sana, pasa a CLOSED y cotiza de nuevo con máxima precisión.
Ejemplo 3 Plataforma de Streaming (App Principal → Motor de Recomendaciones)
Al abrir la app de streaming, la pantalla de inicio consulta al motor para mostrar las películas recomendadas para ti.
Abres la app. El motor de recomendaciones analiza tus gustos y responde en 150ms. Tu pantalla inicial carga filas personalizadas al instante.
Se estrena una serie viral y millones de personas entran a la vez. El motor de recomendaciones se satura de CPU y tarda 8s en responder (provocando timeouts). El disyuntor salta a OPEN. Al abrir la app, en milisegundos se ejecuta el fallback: carga una lista estática ya guardada en caché de *"Las 10 películas más populares del país"*. La app abre al instante y no frustra al usuario.
Baja la ráfaga de tráfico. Tras el timer, el circuito pasa a HALF-OPEN y envía 5 solicitudes de prueba al motor. Al responder en menos de 200ms, el circuito vuelve a CLOSED y restaura las filas personalizadas.
3. La Evolución de los Circuit Breakers en Spring: De Hystrix a Resilience4j
Durante años, Netflix Hystrix fue el estándar de facto para circuit breakers en el ecosistema Spring Cloud. Sin embargo, Netflix depreco oficialmente Hystrix a finales de 2018, instando a los desarrolladores a buscar soluciones alternativas.
Por Qué se Deprecó Hystrix
- Diseño Monolítico: Hystrix se construyó sobre una arquitectura pesada y compleja estrechamente ligada a RxJava. Forzaba un modelo de concurrencia específico (pools de hilos de Hystrix) que introducía una sobrecarga de CPU significativa.
- Estado de Mantenimiento: Netflix pasó Hystrix a modo de mantenimiento cuando su enfoque de ingeniería cambió hacia otros desafíos.
| Métrica / Característica | Netflix Hystrix | Resilience4j |
|---|---|---|
| Paradigma de Programación | Reactivo (RxJava 1.x) | Funcional / Basado en Lambdas (Java 8+) |
| Dependencias | Archaius, Guava, RxJava (Pesado) | Ninguna (Ligero, modular) |
| Modelo de Concurrencia | Aislamiento de Hilos (Bulkhead) o Semáforo | Personalizable (Decoradores Java nativos, Futures) |
| Soporte para Spring Boot 3 | Inexistente | Soporte de primer nivel |
| Consumo de Memoria | Moderado a Alto | Extremadamente Bajo |
4. Conceptos Clave de Resilience4j y Ventanas Deslizantes (Sliding Windows)
Resilience4j utiliza una ventana deslizante (sliding window) para registrar y evaluar los resultados de las llamadas. Esta ventana se puede configurar de dos formas:
1. Ventana Deslizante Basada en Conteo (Count-Based)
La ventana deslizante es un buffer circular de tamaño fijo que contiene las últimas N llamadas. Si el tamaño de la ventana es 100, el circuit breaker evalúa la tasa de fallos basándose únicamente en las últimas 100 ejecuciones.
2. Ventana Deslizante Basada en Tiempo (Time-Based)
La ventana deslizante es un buffer circular que registra los resultados de las ejecuciones que ocurren dentro de los últimos N segundos. Se implementa utilizando agregaciones parciales (buckets) para minimizar el consumo de memoria.
💡 ¿Por qué es crucial que la ventana sea "Deslizante"?
Si el disyuntor no fuera deslizante y acumulara estadísticas históricas perpetuas, millones de llamadas exitosas realizadas durante meses diluirían una caída de servicio reciente. El porcentaje de error parecería del 0.01% y el disyuntor nunca saltaría. La ventana deslizante se mueve constantemente evaluando únicamente el pasado inmediato.
Cinta Transportadora (Basada en Conteo)
Imagina una cinta transportadora donde solo caben las últimas 10 llamadas:
Si llega una nueva llamada y falla (🔴), entra por la derecha y **la más vieja (izquierda) se cae** para hacer espacio:
La tasa se recalcula al instante basándose únicamente en las 10 que quedan activas.
Cubos de Tiempo (Basada en Tiempo)
Para evaluar los últimos 60 segundos sin saturar la memoria, Resilience4j divide el tiempo en porciones o cubos (buckets):
Cada 6 segundos, el cubo más viejo se descarta, todos se desplazan a la izquierda, y se añade un cubo vacío a la derecha. El cálculo de fallas es la suma agregada de los cubos activos en ese segundo.
Parámetros Críticos de Ajuste (Tuning)
failureRateThreshold: El porcentaje de llamadas fallidas por encima del cual debe saltar el disyuntor (ej.50).slowCallRateThreshold: El porcentaje de llamadas lentas por encima del cual debe saltar el disyuntor (ej.75).slowCallDurationThreshold: El umbral de duración a partir del cual una llamada se categoriza como "lenta" (ej.2000ms).minimumNumberOfCalls: El número mínimo de llamadas (dentro de la ventana deslizante) requeridas antes de que el disyuntor pueda calcular estadísticas.waitDurationInOpenState: El tiempo que el circuit breaker debe permanecer en estadoOPENantes de pasar aHALF-OPEN.
🧠 El Secreto del Éxito: "Olvidar el pasado y quedarse con el presente"
Es un concepto brillante de diseño: El disyuntor toma un pasado inmediato y concreto como la única verdad absoluta y descarta todo el resto del historial. Esto es lo que permite que el sistema actúe con rapidez y precisión en situaciones críticas.
📊 Ejemplo Matemático: ¿Por qué es una ventaja descartar el pasado?
Imagina que tu base de datos funciona perfectamente durante 10 horas y acumula 100,000 llamadas exitosas. De repente, la base de datos se cae por completo y en los siguientes 5 minutos ocurren 500 llamadas fallidas.
- Si midieras todo el historial (sin olvidar): Tu tasa de error acumulada sería de solo
500 / 100,500 = 0.49%. Como está muy por debajo de tu umbral del 50%, el disyuntor pensaría que el sistema está sano y nunca actuaría, provocando que tu servidor colapse por hilos bloqueados. - Usando la Ventana Deslizante (restringiendo a los últimos 30 segundos): El disyuntor observa que en esos 30 segundos hubo 50 llamadas y las 50 fallaron (
100% de error). Ve la "verdad actual", actúa de inmediato saltando a OPEN, y protege tu servidor.
🔄 ¿Cómo influye la ventana para "Actuar" en cada Estado?
Cuando hablamos de "actuar" (ejecutar las transiciones de estado), la ventana deslizante tiene comportamientos muy distintos según dónde nos encontremos:
- En estado CLOSED (Cerrado): La ventana deslizante estándar está activa al 100%. Mide constantemente éxitos y fallas. Si el presente inmediato cruza el umbral configurado, actúa y hace la transición a OPEN.
- En estado OPEN (Abierto): La ventana deslizante se desactiva y se ignora por completo. Como el circuito está abierto, el disyuntor no deja pasar llamadas, por lo que no hay datos que medir. En su lugar, actúa usando un temporizador de espera simple (ej. 15 segundos). Al completarse el tiempo, actúa pasando a HALF-OPEN.
- En estado HALF-OPEN (Semi-abierto): Se activa una ventana de prueba especial y reducida (ej. solo 5 llamadas permitidas). El disyuntor actúa observando exclusivamente este pequeño lote: si alguna falla, vuelve de inmediato a OPEN; si todas tienen éxito, vuelve a la normalidad en CLOSED.
❓ Preguntas Clave de Desarrollo
1. ¿En Java dónde y cómo se configura si la ventana es Count-Based o Time-Based?
En Resilience4j disponemos de dos alternativas de configuración:
Opción A: Declarativa (Recomendada - application.yml)
resilience4j.circuitbreaker:
configs:
default:
# slidingWindowType puede ser: COUNT_BASED (por defecto) o TIME_BASED
slidingWindowType: TIME_BASED
# slidingWindowSize cambia su significado según el tipo:
# - En COUNT_BASED: representará un número de llamadas (ej. 50 llamadas)
# - En TIME_BASED: representará segundos (ej. 50 segundos)
slidingWindowSize: 60
Opción B: Programática (En código Java)
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.slidingWindowType(SlidingWindowType.TIME_BASED) // o COUNT_BASED
.slidingWindowSize(60) // 60 segundos o 60 llamadas según el tipo
.failureRateThreshold(50.0f)
.build();
2. ¿Cómo saber cuándo elegir un tipo de ventana sobre el otro?
La elección depende del volumen y la estabilidad del tráfico de tu servicio:
Usar Count-Based (Basado en Conteo) si:
- El tráfico es **bajo o esporádico** (ej. 10 llamadas por hora). Una ventana de tiempo expiraría sin acumular suficientes llamadas para evaluar.
- Buscas consistencia matemática estricta: *"Si de los últimos 20 intentos fallan 10, cerramos"* sin importar cuánto tiempo pase entre ellos.
Usar Time-Based (Basado en Tiempo) si:
- El tráfico es **muy alto o con picos de ráfaga** (ej. miles por minuto). Una caída de red de solo 2 segundos llenaría una ventana de conteo al instante provocando oscilaciones rápidas.
- Tu acuerdo de nivel de servicio (SLA) se define por tiempo: *"No estar degradados más de 1 minuto consecutivo"*.
| Criterio | Count-Based (Conteo) | Time-Based (Tiempo) |
|---|---|---|
| Bajo Tráfico | Excelente | No recomendado |
| Picos de Tráfico | Puede oscilar | Excelente (Amortigua ráfagas) |
| Memoria | Extremadamente baja | Baja (Requiere cubos de tiempo) |
💡 Consejo Práctico de Arquitectura: Si tienes dudas al comenzar tu proyecto, empieza siempre con Count-Based (ventana de 20 o 50 llamadas). Es mucho más fácil de probar, depurar y entender. Solo migra a Time-Based si notas oscilaciones no deseadas en tus pruebas de carga masiva ante micro-cortes de red transitorios.
5. Configuración de un Proyecto Spring Boot con Resilience4j
Configuremos una aplicación moderna de Spring Boot 3.x con las dependencias requeridas.
Dependencias (Maven - pom.xml)
<!-- Resilience4j Spring Boot Starter -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
<!-- Spring Boot Starter AOP (Requerido para la anotación) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
Configuración en YAML (application.yml)
resilience4j:
circuitbreaker:
configs:
default:
slidingWindowSize: 100
slidingWindowType: COUNT_BASED
minimumNumberOfCalls: 10
failureRateThreshold: 50
slowCallRateThreshold: 75
slowCallDurationThreshold: 2000s
waitDurationInOpenState: 15000s
permittedNumberOfCallsInHalfOpenState: 5
automaticTransitionFromOpenToHalfOpenEnabled: true
instances:
paymentServiceBreaker:
baseConfig: default
slidingWindowSize: 20
failureRateThreshold: 40
6. Implementación Declarativa con `@CircuitBreaker` y Fallbacks
Resilience4j proporciona un aspecto de Spring AOP que intercepta las llamadas anotadas con @CircuitBreaker.
@Service
public class PaymentService {
private final RestTemplate restTemplate;
@CircuitBreaker(name = "paymentServiceBreaker", fallbackMethod = "processPaymentFallback")
public String processPayment(String transactionId, double amount) {
String externalUrl = "https://api.external-payment.com/v1/charge";
return restTemplate.postForObject(externalUrl, new PaymentRequest(transactionId, amount), String.class);
}
// Método de contingencia (fallback)
public String processPaymentFallback(String transactionId, double amount, Throwable exception) {
if (exception instanceof CallNotPermittedException) {
return "PAYMENT_SERVICE_UNAVAILABLE (Fallo Rápido)";
}
return "PAYMENT_PENDING (Almacenado localmente para procesamiento diferido)";
}
}
Reglas Obligatorias para Firmas Fallback: El método fallback debe residir en la misma clase, aceptar los mismos parámetros en el mismo orden más un parámetro final Throwable, y devolver el mismo tipo de retorno.
7. Configuración Avanzada: Gestión de Excepciones y Oyentes
No todas las excepciones representan fallos del sistema o de infraestructura. Por ejemplo, los errores de negocio 4xx.
resilience4j.circuitbreaker:
instances:
orderServiceBreaker:
recordExceptions:
- org.springframework.web.client.HttpServerErrorException # Errores 5xx
- java.util.concurrent.TimeoutException
ignoreExceptions:
- org.springframework.web.client.HttpClientErrorException # Errores 4xx
Suscripción a Eventos de Transición de Estado
@Configuration
public class CircuitBreakerEventListener {
@PostConstruct
public void registerListeners() {
registry.getAllCircuitBreakers().forEach(breaker -> {
breaker.getEventPublisher()
.onStateTransition(event -> log.error("Transición: {}", event.getStateTransition()));
});
}
}
8. Uso Programático de Circuit Breakers
La envoltura programática con lambdas ofrece un control absoluto sobre la ejecución y permite evitar los límites tradicionales del proxy de Spring AOP.
@Service
public class ProgrammaticPaymentService {
private final CircuitBreaker circuitBreaker;
public String processPayment(String txId, double amount) {
Supplier<String> paymentCall = () -> executePayment(txId, amount);
Supplier<String> decorated = CircuitBreaker.decorateSupplier(circuitBreaker, paymentCall);
return Try.ofSupplier(decorated)
.recover(throwable -> "FALLBACK_TRIGGERED")
.get();
}
}
9. Observabilidad, Métricas y Monitoreo en Producción
Spring Boot Actuator se integra nativamente con Micrometer para exponer métricas exactas del estado de tus circuit breakers.
Métricas de Producción Clave
resilience4j_circuitbreaker_state: Estado actual (0 = CLOSED, 1 = OPEN, 2 = HALF-OPEN).resilience4j_circuitbreaker_calls: Llamadas por tipo de resultado (successful, failed, not_permitted).resilience4j_circuitbreaker_failure_rate: Tasa de fallos en tiempo real.
10. Estrategias de Prueba (Unitarias, Integración y Simulación)
Verificar las transiciones de estado programáticas mediante pruebas de integración robustas.
@SpringBootTest
class PaymentServiceIntegrationTest {
@Autowired private PaymentService paymentService;
@Test
void testCircuitBreakerTrips() {
// Simular fallos repetidos
when(restTemplate.postForObject(any(), any(), any()))
.thenThrow(new RuntimeException("Network Error"));
for (int i = 0; i < 11; i++) {
paymentService.processPayment("tx", 10.0);
}
assertEquals(CircuitBreaker.State.OPEN, circuitBreaker.getState());
}
}
11. Buenas Prácticas en Producción y Errores Comunes
Evitar Autollamadas (Self-Invocation): Invocar un método anotado con @CircuitBreaker desde dentro de la misma clase eludirá por completo el proxy AOP, haciendo que el disyuntor no se ejecute.
Ajuste de Timeouts HTTP: Mantenga los timeouts de lectura y conexión del cliente HTTP agresivos (ej. < 2000ms) para que el disyuntor no quede retenido innecesariamente por hilos lentos.