Email copiado — support@tuurt.com
Cargando experiencia
java · 06 de septiembre de 2026 · 6 min

Circuit breakers con Resilience4j en Spring Boot: evitar que un proveedor caído tumbe el resto del sistema

Un timeout sin circuit breaker sigue agotando threads mientras el proveedor externo sigue caído. Vemos como Resilience4j corta las llamadas a tiempo con CircuitBreaker, TimeLimiter y un bulkhead de threads dedicado.

Por Equipo Tuurt

Circuit breakers con Resilience4j en Spring Boot: evitar que un proveedor caído tumbe el resto del sistema

Un microservicio Spring Boot típico atiende peticiones HTTP con un pool de threads limitado: por defecto, el conector de Tomcat embebido usa 200 threads como máximo, y cada petición entrante ocupa uno de esos threads mientras se procesa. Mientras el procesamiento sea rápido, ese límite nunca se nota. El problema aparece cuando parte de ese procesamiento incluye una llamada de red hacia un servicio externo, y ese servicio empieza a responder lento en vez de responder con un error. Un timeout mal configurado -o directamente ausente- en el cliente HTTP hace que cada request que depende de ese proveedor mantenga su thread ocupado, esperando, durante mucho más tiempo del que el sistema puede permitirse. Si eso pasa con suficiente frecuencia, el pool de threads se agota, y con él se agota la capacidad del servicio para atender cualquier otro endpoint, incluidos los que no tienen nada que ver con el proveedor caído.

Este es el escenario que nos encontramos en un servicio de checkout que integraba con un proveedor externo de verificación de fraude. El proveedor tuvo un incidente de disponibilidad y empezó a tardar entre 20 y 30 segundos en responder, en vez de fallar rápido con un error. El cliente HTTP del servicio no tenía timeout explícito configurado, así que cada request de checkout que pasaba por la verificación de fraude quedaba bloqueada esperando esa respuesta. En minutos, el pool de Tomcat se llenó de threads esperando al proveedor, y el endpoint de consulta de estado de pedidos -que no llama al proveedor de fraude para nada- empezó a devolver timeouts también, simplemente porque no quedaban threads libres para atenderlo.

Por qué un timeout por sí solo no alcanza

La primera reacción, razonable, fue agregar un timeout explícito al cliente HTTP:

RestClient restClient = RestClient.builder()
    .requestFactory(ClientHttpRequestFactorySettings.DEFAULTS
        .withConnectTimeout(Duration.ofSeconds(2))
        .withReadTimeout(Duration.ofSeconds(3))
        .build())
    .build();

Esto acota el daño de cada request individual: en vez de esperar 30 segundos, cada llamada al proveedor de fraude falla a los 3 segundos. Pero no resuelve el problema de fondo, porque si el proveedor sigue caído, el servicio sigue intentando la llamada en cada request nueva, y sigue gastando 3 segundos de un thread por cada intento fallido. Con suficiente volumen de tráfico, 3 segundos por request siguen siendo suficientes para agotar el pool, solo que un poco más lento que con 30. El timeout evita que una llamada individual se cuelgue para siempre, pero no evita que el sistema siga insistiendo en llamar a un proveedor que ya sabemos que no está respondiendo.

Lo que falta es un mecanismo que, después de ver fallar varias llamadas seguidas, deje de intentar la llamada real durante un tiempo y responda de inmediato con un fallback, dándole al proveedor margen para recuperarse y liberando threads en el propio servicio. Eso es lo que hace un circuit breaker.

CircuitBreaker y TimeLimiter con Resilience4j

Resilience4j expone el patrón como una combinación de dos mecanismos independientes que se decoran juntos: TimeLimiter, que acota cuánto se espera una operación asíncrona, y CircuitBreaker, que lleva la cuenta de fallos recientes y decide si permite o no un nuevo intento.

La configuración vive en application.yml:

resilience4j:
  circuitbreaker:
    instances:
      fraudCheck:
        sliding-window-type: COUNT_BASED
        sliding-window-size: 20
        minimum-number-of-calls: 10
        failure-rate-threshold: 50
        wait-duration-in-open-state: 15s
        permitted-number-of-calls-in-half-open-state: 5
  timelimiter:
    instances:
      fraudCheck:
        timeout-duration: 3s
  thread-pool-bulkhead:
    instances:
      fraudCheck:
        max-thread-pool-size: 10
        core-thread-pool-size: 5

sliding-window-size: 20 con minimum-number-of-calls: 10 significa que el circuit breaker evalúa el porcentaje de fallo sobre las últimas 20 llamadas, pero no toma ninguna decisión hasta haber visto al menos 10. Con failure-rate-threshold: 50, si la mitad o más de esas llamadas fallan, el circuito pasa a estado abierto: durante los siguientes 15 segundos (wait-duration-in-open-state), ninguna llamada real se intenta, y cada invocación devuelve el fallback de inmediato, sin gastar ningún thread esperando al proveedor. Pasado ese tiempo, el circuito entra en estado semiabierto y permite 5 llamadas de prueba; si esas tienen buen resultado, el circuito se cierra y vuelve a operar con normalidad, y si vuelven a fallar, se abre de nuevo por otros 15 segundos.

El punto que suele pasarse por alto es que el TimeLimiter necesita operar sobre un CompletableFuture ejecutado en un thread pool separado del que atiende la petición HTTP entrante. Si el timeout se aplica sobre una llamada bloqueante ejecutada en el mismo thread del request, ese thread sigue ocupado hasta que la llamada bloqueante retorna -aunque el TimeLimiter ya haya decidido, desde afuera, que la operación tardó demasiado-. El thread-pool-bulkhead de la configuración de arriba existe justamente para eso: aísla las llamadas al proveedor de fraude en su propio pool de 10 threads como máximo, separado del pool de Tomcat que atiende el resto de los endpoints.

El código del cliente queda así:

@Service
public class FraudCheckClient {

    private final RestClient restClient;

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

    @CircuitBreaker(name = "fraudCheck", fallbackMethod = "fallback")
    @TimeLimiter(name = "fraudCheck")
    @Bulkhead(name = "fraudCheck", type = Bulkhead.Type.THREADPOOL)
    public CompletableFuture<FraudVerdict> check(Order order) {
        return CompletableFuture.supplyAsync(() ->
            restClient.post()
                .uri("/verify")
                .body(new FraudCheckRequest(order.customerId(), order.amount()))
                .retrieve()
                .body(FraudVerdict.class)
        );
    }

    private CompletableFuture<FraudVerdict> fallback(Order order, Throwable ex) {
        return CompletableFuture.completedFuture(FraudVerdict.manualReview());
    }
}

El método de fallback recibe la misma firma que el método original más la excepción que causó la falla -sea un timeout, un error de conexión, o el circuito ya abierto-, y en este caso devuelve un veredicto de revisión manual en vez de aprobar o rechazar el pedido automáticamente. Esa decisión de negocio es importante: el fallback no puede fingir que el proveedor dijo que todo está bien, porque eso desactivaría la verificación de fraude sin que nadie lo note. Marcar el pedido para revisión manual es una degradación explícita y visible, no una aprobación silenciosa.

Lo que no funcionó: circuit breaker sin bulkhead de threads

La primera versión que desplegamos tenía el CircuitBreaker y el TimeLimiter, pero sin el Bulkhead de tipo THREADPOOL. El TimeLimiter seguía ejecutando el CompletableFuture con supplyAsync sin un executor explícito, lo cual en la práctica usa el ForkJoinPool común de la JVM, compartido con cualquier otro código de la aplicación que también use CompletableFuture sin especificar executor. Cuando el proveedor de fraude empezó a fallar, el circuito se abrió correctamente y dejó de intentar llamadas reales, pero mientras estaba semiabierto, las llamadas de prueba seguían compitiendo por threads del ForkJoinPool común con otras tareas asíncronas del servicio que no tenían nada que ver con fraude, y esas tareas empezaron a verse retrasadas sin ninguna relación aparente con el proveedor externo. Aislar las llamadas al proveedor en su propio thread-pool-bulkhead, con un tamaño acotado y dedicado, resolvió esa interferencia cruzada.

El criterio que quedó

No todo timeout necesita un circuit breaker encima. Si una llamada externa falla de forma aislada y ocasional, reintentar con backoff simple alcanza. El circuit breaker se vuelve necesario cuando una falla del proveedor tiende a repetirse en ráfaga -el proveedor entero está caído o degradado, no una petición puntual- y cuando seguir intentando durante esa ráfaga consume un recurso compartido, como el pool de threads que también atiende tráfico que no depende de ese proveedor. En ese escenario, dejar de intentar durante un rato y responder con un fallback explícito no es una degradación del servicio: es lo que evita que un problema de un proveedor externo se convierta en una caída completa del sistema propio.

java spring-boot resilience4j microservicios
← Volver al blog