Saltar al contenido principal

Circuit Breaker: el fusible que evita que tu microservicio se hunda

11 min lectura
Patrón Circuit Breaker explicado con una simulación interactiva y un ejemplo real con Spring Boot y Resilience4j. Cuándo usarlo y cuándo no.
Escuchar artículo

Es viernes a las cinco de la tarde. El servicio de pagos de tu proveedor empieza a responder en 10 segundos en lugar de 200ms. Tu microservicio sigue llamándolo, sin enterarse de que algo va mal. Cada petición ocupa un hilo del pool. En menos de un minuto tienes el tomcat thread pool saturado, los healthchecks fallan, Kubernetes reinicia el pod, los pods vecinos se llevan la avalancha y cae todo el dominio por culpa de un único servicio externo enfermo.

Si has vivido esa escena, sabes que el problema no es que el servicio externo fallara. El problema es que tu aplicación insistió en llamarlo. Sin freno, sin memoria de los fallos anteriores, sin estrategia. Y aquí es donde entra un patrón viejo pero imprescindible: el Circuit Breaker.

Si vienes del mundo del testing, te recomiendo este artículo complementario sobre cómo los mocks no son stubs y por qué la diferencia importa al testear servicios resilientes con Spring Boot.


Por qué necesitas un Circuit Breaker

Cuando una dependencia falla, hay dos cosas que tu código suele hacer mal:

  1. Reintenta sin parar, como si fuera un error puntual.
  2. Bloquea hilos esperando una respuesta que no va a llegar.

Multiplica eso por el volumen de tráfico real y tienes el típico fallo en cascada: un servicio downstream caído arrastra al servicio upstream, que arrastra al siguiente. En arquitecturas de microservicios este efecto dominó es brutal porque cada llamada bloqueada consume memoria, conexiones HTTP y, sobre todo, hilos.

El Circuit Breaker es exactamente lo que parece: un interruptor eléctrico para tus llamadas remotas. Cuando detecta que el servicio destino acumula demasiados fallos, abre el circuito y deja de enviarle peticiones durante un tiempo. Devuelve un error inmediato (o ejecuta un fallback) sin pasar por la red. Cuando ha pasado un margen de espera, vuelve a probar con cuidado para ver si la cosa se ha recuperado.

Tres estados, ni uno más:

EstadoQué haceCuándo cambia
CLOSEDDeja pasar las peticiones, pero las observa.Si el porcentaje de fallos supera el umbral → OPEN.
OPENFalla rápido sin tocar el servicio.Cuando expira el tiempo de espera → HALF_OPEN.
HALF_OPENDeja pasar unas pocas peticiones de prueba.Si funcionan → CLOSED. Si fallan → OPEN.

El problema concreto que soluciona

Imagina un servicio OrderService que para procesar un pedido necesita llamar a PaymentGateway. Sin Circuit Breaker, el código suele ser así:

// ❌ Sin protección: cualquier latencia del pago tumba el pool de hilos
public OrderResult process(Order order) {
    PaymentResponse response = paymentGateway.charge(order); // bloquea hasta 30s
    return OrderResult.from(response);
}

¿Qué pasa si paymentGateway se queda colgado? Cada petición concurrente ocupa un hilo durante 30 segundos. Con 200 hilos en el pool y un pico de 50 req/s, en 4 segundos no queda capacidad para nada más. Ni para devolver el menú de productos. Ni para responder al healthcheck. Caída total.

Con un Circuit Breaker delante, el comportamiento cambia:

// ✅ Con Circuit Breaker: tras N fallos abre el circuito y deja de llamar
@CircuitBreaker(name = "paymentGateway", fallbackMethod = "fallback")
public OrderResult process(Order order) {
    PaymentResponse response = paymentGateway.charge(order);
    return OrderResult.from(response);
}

private OrderResult fallback(Order order, Throwable ex) {
    return OrderResult.pending(order, "Pago en cola para reintento asíncrono");
}

El Circuit Breaker no soluciona el fallo del servicio externo (eso depende de quien lo opere). Lo que soluciona es proteger a tu aplicación del daño colateral mientras dura el incidente.


Cuándo usarlo y cuándo no

Aquí es donde mucha gente se equivoca: el Circuit Breaker no se pone “por defecto” en todas las llamadas. Tiene coste de configuración, complejidad y memoria. Hay que aplicarlo con criterio.

Cuándo sí

  • Llamadas remotas a otros servicios o APIs externas (HTTP, gRPC, llamadas a otro microservicio).
  • Integraciones con sistemas legacy donde sabes que las caídas son normales.
  • Operaciones idempotentes o con un fallback aceptable (cache, valor por defecto, cola para reintentar).
  • Cuando un fallo en cascada afecta a usuarios reales, no solo a tu logs.

Cuándo no

  • Llamadas a tu base de datos transaccional: si Postgres se cae, tu microservicio está roto de raíz; abrir el circuito no ayuda. Mejor un healthcheck que tire el pod.
  • Operaciones críticas sin fallback razonable: si no tienes alternativa válida (no puedes simular un cobro), abrir el circuito solo cambia un error por otro distinto.
  • Llamadas in-memory dentro del mismo proceso: no hay riesgo de saturación de red ni de latencia variable.
  • Scripts batch puntuales: tu CLI corriendo una vez al día no necesita un fusible. Un timeout y un reintento bastan.

Una regla práctica: si tu llamada cruza la frontera del proceso y puede tardar más de lo razonable, mete un Circuit Breaker. Si no, déjalo en paz.


Cómo funciona, visto en directo

La teoría está bien, pero el patrón se entiende mucho mejor cuando lo ves moverse. Pulsa Enviar petición o deja el tráfico automático activado y juega con la tasa de fallo del servicio. Cuando los fallos superan el umbral configurado, el circuit breaker abre el circuito y empieza a rechazar peticiones sin tocar el servicio. Pasados unos segundos, entra en HALF_OPEN y prueba con peticiones de sondeo.

Simulador interactivo del Circuit Breaker

Ajusta la tasa de fallos del servicio y observa cómo el circuit breaker abre, cierra o entra en estado HALF_OPEN para proteger tu aplicación.

Controles
20%
50%
10req
8s
Visualización
Console Output
Activa el tráfico automático o pulsa 'Enviar petición' para arrancar el circuito...
Métricas del Algoritmo
Tasa fallo % vs Peticiones

Cosas que merece la pena observar:

  1. Sube la tasa de fallo a 70% y mira cómo el circuito se abre en cuestión de pocas peticiones.
  2. Una vez en OPEN, fíjate que las peticiones nuevas se devuelven al instante (en rojo): no hay latencia ni consumo del servicio.
  3. Cuando pasa el tiempo de espera y entra en HALF_OPEN, baja la tasa de fallo al 5% para simular que el servicio se ha recuperado y verás cómo el circuito vuelve a CLOSED.

Ejemplo práctico con Spring Boot y Resilience4j

Spring Cloud Circuit Breaker es la abstracción oficial de Spring, pero por debajo casi todo el mundo termina usando Resilience4j, que es la implementación más madura y mejor mantenida. Vamos a montarlo desde cero.

1. Dependencias

<!-- pom.xml -->
<dependencies>
    <dependency>
        <groupId>io.github.resilience4j</groupId>
        <artifactId>resilience4j-spring-boot3</artifactId>
        <version>2.2.0</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
</dependencies>

El starter de aop no es opcional: las anotaciones @CircuitBreaker funcionan vía AOP.

2. Configuración en application.yml

resilience4j:
  circuitbreaker:
    instances:
      paymentGateway:
        slidingWindowType: COUNT_BASED       # ventana basada en últimas N llamadas
        slidingWindowSize: 20                # ojo a las últimas 20 peticiones
        minimumNumberOfCalls: 10             # no decidas con menos de 10 muestras
        failureRateThreshold: 50             # abre si fallan >= 50%
        waitDurationInOpenState: 30s         # 30s antes de probar de nuevo
        permittedNumberOfCallsInHalfOpenState: 3
        slowCallRateThreshold: 80            # también abre si el 80% son lentas
        slowCallDurationThreshold: 2s        # "lenta" = >2s
        automaticTransitionFromOpenToHalfOpenEnabled: true

Empieza siempre con valores conservadores y los ajustas con datos reales en producción. No copies números mágicos de un blog (incluido este) sin medir tu propio tráfico.

3. El servicio protegido

// PaymentService.java
@Service
public class PaymentService {

    private final RestClient restClient;

    public PaymentService(RestClient restClient) {
        this.restClient = restClient;
    }

    @CircuitBreaker(name = "paymentGateway", fallbackMethod = "fallback")
    public PaymentResponse charge(PaymentRequest request) {
        return restClient.post()
            .uri("https://payments.example.com/charge")
            .body(request)
            .retrieve()
            .body(PaymentResponse.class);
    }

    // El fallback debe tener la MISMA firma + un Throwable al final
    private PaymentResponse fallback(PaymentRequest request, Throwable ex) {
        log.warn("Pago indisponible para {}: {}", request.orderId(), ex.getMessage());
        return PaymentResponse.pending(request.orderId(), "PENDING_RETRY");
    }
}

Dos puntos clave que se suelen pasar por alto:

  • El método de fallback debe ser privado, devolver el mismo tipo y aceptar el Throwable como último parámetro. Si la firma no encaja, Resilience4j lanza una excepción rara en tiempo de ejecución y pierdes horas.
  • El fallback no es un sitio para meter lógica de negocio compleja. Devuelve un valor seguro, un pending, un valor cacheado. Si dentro del fallback haces otra llamada remota, vuelves al problema original.

4. Combinando con Retry y Timeout

El Circuit Breaker rara vez va solo. La receta más estable es:

@TimeLimiter(name = "paymentGateway")    // corta llamadas largas
@CircuitBreaker(name = "paymentGateway", fallbackMethod = "fallback")
@Retry(name = "paymentGateway")          // 2-3 reintentos rápidos
public CompletableFuture<PaymentResponse> charge(PaymentRequest request) {
    return CompletableFuture.supplyAsync(() -> restClient.post()
        .uri("/charge")
        .body(request)
        .retrieve()
        .body(PaymentResponse.class));
}

El orden de las anotaciones importa: TimeLimiter envuelve a CircuitBreaker, que envuelve a Retry. Así un fallo transitorio se reintenta antes de contarlo como fallo “real” en la ventana del circuit breaker.

5. Observabilidad: no abras el circuito a ciegas

Tan importante como configurar el patrón es enterarte cuando se dispara. Resilience4j publica eventos que puedes engancharte a tu sistema de alertas:

@Component
public class CircuitBreakerEvents {

    public CircuitBreakerEvents(CircuitBreakerRegistry registry, AlertService alerts) {
        registry.getAllCircuitBreakers().forEach(cb ->
            cb.getEventPublisher().onStateTransition(event -> {
                var transition = event.getStateTransition();
                log.info("[CB {}] {} → {}",
                    event.getCircuitBreakerName(),
                    transition.getFromState(),
                    transition.getToState());

                if (transition.getToState() == CircuitBreaker.State.OPEN) {
                    alerts.warn("Circuit breaker " + event.getCircuitBreakerName() + " abierto");
                }
            }));
    }
}

Y expón los endpoints de Actuator para Grafana / Prometheus:

management:
  endpoints:
    web:
      exposure:
        include: health,circuitbreakers,circuitbreakerevents,prometheus
  health:
    circuitbreakers:
      enabled: true

Un Circuit Breaker que se abre y nadie se entera es solo deuda escondida.


Errores comunes que te van a pasar

He visto el mismo conjunto de fallos en proyectos muy distintos. Tenlos a mano:

  • Umbrales demasiado agresivos: pones failureRateThreshold: 20 y el circuito se abre con cualquier blip. Empieza por 50% y baja con datos.
  • Sin minimumNumberOfCalls: con dos peticiones fallidas en frío el circuito se abre. Pide al menos 10-20 muestras antes de decidir.
  • Fallbacks que hacen más llamadas remotas: invalidan todo el patrón. El fallback debe ser barato y local.
  • No registrar las excepciones a ignorar: hay errores de cliente (4xx) que no son culpa del servicio. Si los cuentas como fallos, abres el circuito sin razón.
  • Usar Circuit Breaker para ocultar bugs: si tu código falla porque hay un NullPointerException, el patrón no te salva. Arregla el bug.

Mi opinión: úsalo, pero con disciplina

El Circuit Breaker es uno de esos patrones que parecen mágicos hasta que te tocan los pelos en producción. Bien usado, te ahorra incidentes y reduce el blast radius cuando un servicio externo se hunde. Mal usado, mete latencia, complejidad y falsa seguridad.

Para mí, el orden correcto es siempre el mismo: primero timeouts agresivos en todas las llamadas remotas (sin esto no hay nada que rascar), luego retry con backoff exponencial para errores transitorios, y solo entonces Circuit Breaker para cortar el grifo cuando el servicio downstream está realmente enfermo. Si saltas pasos, terminas con un sistema que pretende ser resiliente pero no lo es.

Si nunca has usado Resilience4j, mi consejo: monta un servicio de prueba con Spring Boot 3, lánzalo contra un endpoint local que falle el 70% del tiempo y juega con los parámetros. En media hora tendrás la intuición que ningún diagrama de estados te puede dar.

Y si quieres profundizar en cómo testear este tipo de servicios sin acabar con mocks por todas partes, lee mocks no son stubs: la diferencia que cambia cómo testeas en Spring Boot.


Preguntas frecuentes

¿Circuit Breaker es lo mismo que un Retry?

No. El Retry asume que el fallo es transitorio y vuelve a llamar enseguida. El Circuit Breaker asume que el fallo es persistente y deja de llamar durante un tiempo. Se complementan: primero reintentas con backoff, luego abres el circuito si los reintentos no dan resultado.

¿Qué pasa con los fallos del cliente (errores 4xx)?

Por defecto cuentan como fallos. Eso suele estar mal: un 400 Bad Request indica un bug en tu cliente, no que el servicio esté caído. Configura ignoreExceptions o usa recordExceptions para contar solo los errores que indican problema en el servidor (5xx, timeouts, errores de red).

¿Funciona con WebFlux y código reactivo?

Sí. Resilience4j tiene operadores reactivos para Mono y Flux, y Spring Cloud Circuit Breaker expone ReactiveCircuitBreakerFactory. El patrón es el mismo, solo cambia la forma en que decoras la llamada.

¿Puedo cambiar la configuración en caliente?

Sí, pero con cuidado. Resilience4j permite recargar la configuración via Actuator o vía CircuitBreakerRegistry. Útil para ajustar umbrales sin desplegar, pero no abuses: cambios en caliente sin trazabilidad acaban siendo un dolor en post-mortem.

¿Hace falta un Circuit Breaker si ya tengo Istio / Envoy / un service mesh?

Si el mesh tiene circuit breaking activado, gran parte del trabajo ya está hecho en el lado de red. Aun así, suele compensar mantener el patrón en el código para casos donde el mesh no llega (llamadas a SaaS externos, APIs públicas) y para tener un fallback de aplicación con lógica de negocio, no solo un 503 genérico.

Artículos relacionados

Newsletter

Aprende algo útil cada semana.

Ideas sobre programación, arquitectura e IA para mejorar tu código en menos de 5 minutos de lectura.

Sin spam · Baja cuando quieras