Circuit breaker
@CircuitBreaker deja de llamar a una dependencia que falla una y otra vez, y más tarde deja pasar unas pocas llamadas de prueba para ver si se recuperó.
| Anotación | Posición | Tipos de retorno | Valores por defecto |
|---|---|---|---|
@CircuitBreaker |
3 de 7, dentro de Retry | sync y CompletionStage |
ventana de 100 llamadas, abre con 50% de fallas tras 100 llamadas, 60 s abierto, 10 pruebas, detección de llamadas lentas desactivada |
Úsalo cuando una dependencia puede estar caída o sobrecargada un buen rato, y fallar rápido es mejor que acumular llamadas, threads y reintentos esperándola.
No lo uses cuando las fallas son independientes entre sí (una entrada mala no predice la siguiente llamada), o cuando no hay nada útil que hacer mientras está abierto. Un breaker sobre llamadas poco frecuentes (unas pocas por hora) nunca ve suficientes llamadas para decidir; baja minimumCalls o usa una ventana por tiempo.
Ejemplo mínimo
import dev.resilience4cata.annotation.CircuitBreaker;
public interface PaymentGateway {
@CircuitBreaker(name = "payments", windowSize = 10, minimumCalls = 10, failureRateThreshold = 50,
waitInOpen = "30s", halfOpenCalls = 2)
String charge(String orderId);
}
public final class PaymentGateway$Resilient implements PaymentGateway {
// …
public PaymentGateway$Resilient(PaymentGateway delegate, dev.resilience4cata.ResilienceRegistry registry) {
// …
this.circuitBreaker0 = registry.circuitBreakerPolicy("dev.resilience4cata.examples.docs.circuitbreaker.PaymentGateway#charge(java.lang.String)", "payments", "", r4c$b -> {
r4c$b.windowSize(10);
r4c$b.minimumCalls(10);
r4c$b.failureRateThreshold(50.0f);
r4c$b.waitInOpen(java.time.Duration.ofNanos(30000000000L));
r4c$b.halfOpenCalls(2);
});
}
@Override
public java.lang.String charge(java.lang.String p0) {
final dev.resilience4cata.generated.CircuitBreakerPolicy r4c$cb = this.circuitBreaker0;
final long r4c$token = r4c$cb.acquire();
if (r4c$token < 0) {
throw r4c$cb.rejection();
}
final long r4c$started = r4c$cb.startTimer();
try {
final java.lang.String r4c$result = this.delegate.charge(p0);
r4c$cb.onSuccess(r4c$token, r4c$started);
return r4c$result;
} catch (java.lang.Throwable r4c$e) {
r4c$cb.onFailure(r4c$token, r4c$started, r4c$e);
throw r4c$e;
}
}
}
Abreviado: el archivo real usa nombres totalmente calificados.
Este breaker mira las últimas 10 llamadas. Una vez que vio 10, abre cuando el 50% o más de ellas fallaron, rechaza todas las llamadas durante 30 s y luego deja pasar 2 llamadas de prueba: si falla menos del 50% de ellas se cierra; si no, vuelve a abrir.
Cómo se comporta
stateDiagram-v2
[*] --> CLOSED
CLOSED --> OPEN: tasa de fallas o de llamadas lentas ≥ umbral<br/>(después de minimumCalls)
OPEN --> HALF_OPEN: primera llamada después de waitInOpen
HALF_OPEN --> CLOSED: pruebas terminadas,<br/>tasas bajo los umbrales
HALF_OPEN --> OPEN: pruebas terminadas, tasa ≥ umbral,<br/>o pasó maxWaitInHalfOpen
CLOSED --> FORCED_OPEN: forceOpen()
OPEN --> FORCED_OPEN: forceOpen()
HALF_OPEN --> FORCED_OPEN: forceOpen()
CLOSED --> DISABLED: disable()
OPEN --> DISABLED: disable()
HALF_OPEN --> DISABLED: disable()
FORCED_OPEN --> CLOSED: reset()
DISABLED --> CLOSED: reset()
- Cerrado. Las llamadas pasan y sus resultados entran en una ventana deslizante. Con
COUNT(por defecto) la ventana guarda las últimaswindowSizellamadas; conTIMEguarda las llamadas de los últimoswindowSizesegundos, en buckets de un segundo. - Qué cuenta como falla. Una excepción que es instancia de un tipo de
recordOn(por defecto:Exception) y de ningún tipo deignoreOn.ignoreOngana: esas llamadas no cuentan para ningún lado. Cualquier otro throwable cuenta como éxito, así que por defecto unErrorno abre el breaker. Con la detección de llamadas lentas activa (slowCallDuration> 0), una llamada que tardaslowCallDurationo más también cuenta como lenta, haya tenido éxito o no. - Apertura. Las tasas se evalúan una vez que la ventana tiene
minimumCallsllamadas (en una ventana por conteo,minimumCallstiene tope enwindowSize). El breaker abre cuando la tasa de fallas llega afailureRateThresholdo la supera, o la tasa de llamadas lentas llega aslowCallRateThresholdo la supera. - Abierto. Toda llamada se rechaza enseguida con
CallNotPermittedException, sin llegar al método. El breaker no se mueve solo: la primera llamada que llega después dewaitInOpenlo pasa a half-open y se convierte en prueba. - Half-open. Se dejan pasar
halfOpenCallsllamadas de prueba; las demás se rechazan hasta que las pruebas terminan. Cuando termina la última prueba, se aplican los mismos umbrales a las pruebas: por debajo, el breaker se cierra con una ventana vacía; si no, vuelve a abrir. SimaxWaitInHalfOpenestá definido y las pruebas no terminaron todas en ese tiempo, la siguiente llamada lo vuelve a abrir. Una prueba ignorada (una excepción deignoreOn, oIGNOREde un classifier) devuelve su permiso. - Forzado abierto y desactivado se ponen a mano, con el handle:
forceOpen()rechaza todo,disable()deja pasar todo y no registra nada, ambos hastareset().enabled = "false"arranca el breaker enDISABLED, yreset()entonces vuelve aDISABLED. - El resultado de una llamada se descarta si el breaker cambió de estado mientras corría, así que una llamada lenta que empezó con el breaker cerrado no puede decidir una prueba de half-open.
El rechazo, dev.resilience4cata.CallNotPermittedException, es una RuntimeException con instanceId(). Se crea una vez por breaker y no tiene stack trace, así que un rechazo no cuesta nada; su mensaje nombra al breaker.
Atributos
| Atributo | Tipo | Por defecto | Clave de config | Significado |
|---|---|---|---|---|
windowType |
WindowType |
COUNT |
defaults.circuitBreaker.windowType |
COUNT: las últimas windowSize llamadas. TIME: las llamadas de los últimos windowSize segundos. |
windowSize |
int |
100 |
defaults.circuitBreaker.windowSize |
Llamadas (COUNT) o segundos (TIME). Al menos 1. |
minimumCalls |
int |
100 |
defaults.circuitBreaker.minimumCalls |
Llamadas necesarias antes de evaluar las tasas. Al menos 1. |
failureRateThreshold |
float |
50 |
defaults.circuitBreaker.failureRateThreshold |
Tasa de fallas, en porcentaje (0 < x ≤ 100), que abre el breaker. |
slowCallRateThreshold |
float |
100 |
defaults.circuitBreaker.slowCallRateThreshold |
Tasa de llamadas lentas, en porcentaje (0 < x ≤ 100), que abre el breaker. |
slowCallDuration |
duración | 0 (desactivado) |
defaults.circuitBreaker.slowCallDuration |
Duración a partir de la cual una llamada es lenta. 0 desactiva la detección: las llamadas no se cronometran. |
waitInOpen |
duración | 60s |
defaults.circuitBreaker.waitInOpen |
Cuánto tiempo queda abierto antes de que la siguiente llamada pueda probar. |
halfOpenCalls |
int |
10 |
defaults.circuitBreaker.halfOpenCalls |
Llamadas de prueba permitidas en half-open. Al menos 1. |
maxWaitInHalfOpen |
duración | 0 (sin límite) |
defaults.circuitBreaker.maxWaitInHalfOpen |
Tiempo máximo en half-open antes de volver a abrir si las pruebas no terminaron. |
recordOn |
Class<? extends Throwable>[] |
{Exception.class} |
defaults.circuitBreaker.recordOn |
Excepciones registradas como fallas. Los demás throwables cuentan como éxitos. |
ignoreOn |
Class<? extends Throwable>[] |
{} |
defaults.circuitBreaker.ignoreOn |
Excepciones que no cuentan para ningún lado; gana sobre recordOn y sobre el classifier. |
classifier |
Class<?> |
ninguno | solo anotación | Un Classifier<T> que registra resultados y excepciones según diga. Reemplaza a recordOn. Ver Classifiers. |
name |
String |
un breaker por método | solo anotación | Id de la instancia; los métodos con el mismo name comparten un breaker. |
config |
String |
default |
solo anotación | Configuración con nombre de la que partir. |
locked |
boolean |
false |
solo anotación | Conserva los valores fijados en la anotación: las claves instances.<id>.* no pueden cambiarlos. Ver Bloquear una instancia. |
enabled |
String |
"true" |
defaults.circuitBreaker.enabled |
"" (heredar), "true" o "false" (arranca en DISABLED). |
Los valores por defecto son los de Resilience4j, salvo que la detección de llamadas lentas está desactivada. Las claves de config también aceptan kebab-case (circuit-breaker.wait-in-open) y existen bajo configs.<name>. e instances.<id>.; ver Configuración.
Un breaker que mira el último minuto, trata como lentas las llamadas de 2 s o más e ignora las entradas inválidas:
@CircuitBreaker(windowType = WindowType.TIME, windowSize = 60, minimumCalls = 5,
slowCallDuration = "2s", slowCallRateThreshold = 80,
ignoreOn = IllegalArgumentException.class)
long balance(String account);
Abre una vez que vio 5 llamadas en los últimos 60 s y el 80% o más fueron lentas (o el 50% o más fallaron). Una IllegalArgumentException es culpa de quien llama, no de la dependencia, así que no se registra en absoluto.
Compartir e ids de instancia
Un breaker tiene estado, así que compartirlo importa. Sin name, cada método tiene su propio breaker, con el id <interfaz>#<método>(<tipos de parámetros>). Con el mismo name en varios métodos (o en varias interfaces envueltas con el mismo registry), comparten un breaker: las fallas de un método lo abren para todos. Deben resolver la misma configuración, o envolverlos falla con R4C-REG-008. Un Pipeline cuyo id es igual al name también lo comparte.
Handle
registry.circuitBreaker(id) devuelve un CircuitBreakerHandle para inspeccionar el breaker y forzar su estado, en operación y en tests:
CircuitBreakerHandle breaker = registry.circuitBreaker("payments");
CircuitBreakerSnapshot snapshot = breaker.snapshot();
System.out.printf("%s is %s, %d calls, failure rate %.0f%%%n",
snapshot.instance(), snapshot.state(), snapshot.bufferedCalls(), snapshot.failureRate());
breaker.forceOpen(); // maintenance window: every call is rejected
breaker.disable(); // or let every call through, recording nothing
breaker.reset(); // back to CLOSED with an empty window
| Método | Qué hace |
|---|---|
id() |
El name, o el id derivado |
state() |
CLOSED, OPEN, HALF_OPEN, FORCED_OPEN o DISABLED |
config() |
La CircuitBreakerConfig resuelta |
snapshot() |
Un CircuitBreakerSnapshot: estado, tasas y conteos de la ventana actual |
forceOpen() |
Rechaza toda llamada hasta reset(), sean cuales sean los resultados |
disable() |
Deja pasar toda llamada y no registra nada hasta reset() |
reset() |
Vuelve al estado inicial (CLOSED, o DISABLED si enabled = false) con una ventana vacía |
registry.circuitBreakers() lista todos los breakers creados hasta ahora, ordenados por id. Un breaker existe una vez que su interfaz se envolvió (Resilient.of/create) o su pipeline se construyó; un id desconocido lanza IllegalArgumentException R4C-REG-009, con una sugerencia "did you mean".
Con otras políticas
El breaker es la tercera capa: dentro de Retry, alrededor de RateLimiter, Timeout y Bulkhead.
- Detiene los reintentos. Cada intento de un
@Retrypasa por el breaker y queda registrado. Una vez que abre, el siguiente intento recibeCallNotPermittedException, que un retry nunca reintenta, digan lo que digan suabortOn, suretryOno su classifier, así que el retry se detiene sin esperar (ver Retry y el resumen). - Registra los rechazos de las políticas internas.
RateLimitExceededException,CallTimeoutExceptionyBulkheadFullExceptionsonException, así que por defecto cuentan como fallas: tu propio rate limiter o bulkhead puede abrir el breaker. Si no quieres eso, ponlas enignoreOn. Los timeouts normalmente sí vale la pena registrarlos. - Las llamadas rechazadas nunca llegan al rate limiter, así que no gastan sus permisos.
- Un
@Fallbacken el método veCallNotPermittedExceptioncomo cualquier otra falla; un método hermano puede recibirla como parámetro para responder distinto mientras el breaker está abierto.
Async
Para un método que devuelve CompletionStage<T> o CompletableFuture<T>, el breaker pide un permiso antes de llamar, registra el resultado cuando el stage se completa y convierte un rechazo en un future fallido (la llamada no lanza ninguna excepción):
@CircuitBreaker(name = "ledger-async", windowSize = 4, minimumCalls = 4)
CompletableFuture<Long> balanceAsync(String account);
Las llamadas lentas se cronometran hasta que el stage se completa. Una llamada cuyo future cancela quien llama no se registra, como una llamada ignorada: un permiso de half-open se devuelve (cancelación). Ver Métodos async.
Lo mismo con Pipeline
Pipeline<String> charge = Pipeline.builder(registry, "payments", String.class)
.circuitBreaker(cb -> cb.windowSize(10).minimumCalls(10).waitInOpen(Duration.ofSeconds(30)))
.build();
String receipt = charge.execute(() -> backend.charge("order-1"));
El id "payments" es el id de instancia del breaker: registry.circuitBreaker("payments") lo devuelve, y un método anotado con name = "payments" y la misma configuración lo comparte. Ver API Pipeline.
Eventos y snapshot
| Evento | Cuándo | Campos |
|---|---|---|
CircuitBreakerEvent.StateTransition |
Cada cambio de estado, publicado una vez, por el thread que lo hizo | instance, nanoTime, from, to |
CircuitBreakerEvent.CallNotPermitted |
Se rechazó una llamada (abierto, forzado abierto o sin pruebas disponibles) | instance, nanoTime, state |
CircuitBreakerSnapshot(instance, state, failureRate, slowCallRate, bufferedCalls, failedCalls, slowCalls): las tasas son porcentajes, o -1 hasta que la ventana tiene minimumCalls llamadas (la tasa de llamadas lentas también es -1 mientras la detección está desactivada). En half-open, los conteos son los de las pruebas. Ver Observabilidad.
Probarlo con ManualClock
PaymentGateway payments = Resilient.of(PaymentGateway.class, backend, registry);
CircuitBreakerHandle breaker = registry.circuitBreaker("payments");
for (int i = 0; i < 5; i++) {
payments.charge("ok-" + i); // 5 successes
}
down.set(true);
for (int i = 0; i < 5; i++) {
assertThatThrownBy(() -> payments.charge("ko")).isInstanceOf(IllegalStateException.class);
}
assertThat(breaker.state()).isEqualTo(CircuitBreakerState.OPEN); // 10 calls, 50% failed
assertThatThrownBy(() -> payments.charge("rejected")).isInstanceOf(CallNotPermittedException.class);
down.set(false);
ResilienceTesting.elapseWaitInOpen(breaker, clock); // 30 s later, on the test's clock
payments.charge("probe-1"); // half-open: 2 probes
payments.charge("probe-2");
assertThat(breaker.state()).isEqualTo(CircuitBreakerState.CLOSED);
Con un ManualClock, waitInOpen, las llamadas lentas y las ventanas por tiempo se miden en ese reloj. ResilienceTesting.elapseWaitInOpen(breaker, clock) lo avanza el waitInOpen del breaker; el breaker sigue reportando OPEN hasta que llega la siguiente llamada. Para poner un breaker directamente en un estado, usa forceOpen(), disable() y reset() en su handle.
Letra chica
- Un
minimumCallsmayor que elwindowSizede una ventana por conteo queda con tope enwindowSize. Una ventana por tiempo no tiene ese tope. - Una ventana por tiempo lee el reloj en cada llamada. Una ventana por conteo solo lo lee si la detección de llamadas lentas está activa.
- Con los valores por defecto, el breaker necesita 100 llamadas antes de poder abrir. Para una dependencia de poco tráfico, baja
minimumCalls(ywindowSize), o usa una ventana por tiempo. - Los umbrales son inclusivos: 5 fallas de 10 abren un breaker con
failureRateThreshold = 50. CallNotPermittedExceptionno tiene stack trace, a propósito. Registra en el log el mensaje oinstanceId().- Errores relacionados:
R4C-ATR-001(sintaxis de duración),R4C-ATR-003(windowSize,minimumCalls,halfOpenCallsmenores que 1),R4C-ATR-005(tasas fuera de (0, 100]),R4C-ATR-006(enabled),R4C-CLS-011(classifierjunto conrecordOn),R4C-REG-004(configdesconocida),R4C-REG-008(namecompartido con otra configuración),R4C-REG-009(id desconocido enregistry.circuitBreaker).
Cuánto cuesta
Measured En AWS Graviton3 (
c7g.xlarge, JDK 21), una llamada exitosa a través de@CircuitBreakercuesta 1,9 ns y no asigna memoria, con 1 thread y con 4; Resilience4j 2.4.0 (ventana por conteo, estrategiaSYNCHRONIZEDpor defecto) tarda 89,2 ns con 1 thread y 832 ns con 4. Con una de cada diez llamadas fallando, con 4 threads: 274 ns contra 910 ns. Benchmarks: cierre del MVP