Migrar desde Resilience4j
Cómo se corresponden los registries, configs, decoradores y anotaciones de Spring de Resilience4j con resilience4cata, qué no tiene equivalente y qué se comporta distinto.
¿Deberías migrar?
resilience4cata cubre ocho políticas (retry, hedge, circuit breaker, rate limiter, adaptive limit, timeout, bulkhead, fallback) para Java 21+ puro, con código generado en tiempo de compilación. Quédate en Resilience4j si necesitas algo de esto, que resilience4cata todavía no tiene:
- integración con Spring Boot 3, CDI o AspectJ (resilience4cata se integra solo con Spring Boot 4.1+, más abajo);
- operadores de Reactor, RxJava o corrutinas de Kotlin (resilience4cata solo admite
CompletionStage/CompletableFuture); - un bulkhead con pool de threads, una caché o un time limiter para futures arbitrarios;
- cambiar la configuración de una política en tiempo de ejecución (resilience4cata resuelve la configuración una sola vez, por diseño);
- una versión de Java anterior a la 21.
Considera migrar si te importan el overhead y la asignación de memoria por llamada (consulta Rendimiento), si quieres detectar los errores de configuración al compilar, o si prefieres anotaciones simples y código generado que puedes leer en lugar de proxies de AOP.
El modelo mental
| Resilience4j | resilience4cata | |
|---|---|---|
| Dónde viven las políticas | decoradores (Decorators.ofSupplier(…), Retry.decorateSupplier(…)) o aspectos de Spring AOP alrededor de un bean |
una clase generada en tiempo de compilación: <Interface>$Resilient envuelve tu implementación, o <Class>$Resilient extiende tu clase |
| Cuándo se detecta un error de configuración | al arrancar o en la primera llamada | en su mayoría al compilar (códigos de error) |
| Orden de las políticas | el orden en que compones los decoradores; con Spring, un orden fijo de aspectos (configurable) | por defecto Fallback → Retry → CircuitBreaker → RateLimiter → AdaptiveLimit → Timeout → Bulkhead → método; @PolicyOrder lo cambia por método, tipo o bundle |
| Registries | uno por política (CircuitBreakerRegistry, RetryRegistry, …) |
un solo ResilienceRegistry para todas las políticas |
| Instancias | se buscan por nombre | una por método anotado de forma predeterminada; los métodos con el mismo name comparten una |
Correspondencia de conceptos
| Resilience4j | resilience4cata |
|---|---|
CircuitBreakerRegistry.of(config), RetryRegistry.of(…), … |
ResilienceRegistry.builder()…build() |
CircuitBreakerConfig.custom()…build() registrado como config con nombre |
registryBuilder.circuitBreaker("name", b -> b…), usado con @CircuitBreaker(config = "name"); o configs.<name>.circuitBreaker.* en properties |
config predeterminada (configs.default en Spring) |
registryBuilder.circuitBreakerDefaults(b -> …) o defaults.circuitBreaker.* |
registry.circuitBreaker("inventory") + decorateSupplier |
@CircuitBreaker(name = "inventory") en el método de la interfaz + Resilient.of(…) |
Decorators.ofSupplier(…).withRetry(…).withCircuitBreaker(…) para código que no puedes anotar |
Pipeline.builder(registry, id).retry().circuitBreaker()…build() |
@CircuitBreaker(name = "x", fallbackMethod = "f") de Spring |
@CircuitBreaker(name = "x") + @Fallback(method = "f") |
@Retry, @RateLimiter, @Bulkhead, @TimeLimiter de Spring |
@Retry, @RateLimiter, @Bulkhead, @Timeout (paquete dev.resilience4cata.annotation) |
resilience4j.circuitbreaker.instances.x.* |
instances.x.circuitBreaker.* (claves) |
baseConfig: other |
configs.<name>.extends=other |
circuitBreaker.getEventPublisher().onStateTransition(…) |
registryBuilder.listen(CircuitBreakerEvent.StateTransition.class, …) (eventos) |
circuitBreaker.getMetrics() |
registry.circuitBreaker(id).snapshot() |
transitionToForcedOpenState(), transitionToDisabledState(), reset() |
forceOpen(), disable(), reset() en CircuitBreakerHandle |
resilience4j-micrometer (TaggedCircuitBreakerMetrics, …) |
resilience4cata-micrometer (Micrometer); los nombres de los meters cambian (r4c.*), así que los dashboards y las alertas necesitan consultas nuevas |
RetryConfig.retryOnResult(…), CircuitBreakerConfig.recordResult(…) |
un Classifier en @Retry / @CircuitBreaker |
failAfterMaxAttempts(true) (MaxRetriesExceededException) |
@Retry(onExhaustedResult = THROW) (ResultRejectedException) |
IntervalFunction.ofExponentialBackoff(…), ofExponentialRandomBackoff(…) |
backoff, multiplier, maxDelay, jitter |
TimeLimiter + un executor para código bloqueante |
@Timeout en el thread de quien llama |
resilience4j-hedge: Hedge.of(config).decorateCompletionStage(…) |
@Hedge en un método que devuelve un CompletableFuture, o Pipeline…hedge(…) con executeAsync (correspondencia) |
ThreadPoolBulkhead, Cache, operadores de Reactor/RxJava/Kotlin |
sin equivalente |
Correspondencia de configuración
Los métodos de los builders de Resilience4j (las properties de Spring Boot usan los mismos nombres en kebab-case) y el atributo de resilience4cata con el mismo papel. Los valores predeterminados difieren en algunos casos; las columnas Predeterminado son las de cada biblioteca.
Circuit breaker
| Resilience4j | Predeterminado | resilience4cata | Predeterminado |
|---|---|---|---|
failureRateThreshold |
50 | failureRateThreshold |
50 |
slowCallRateThreshold |
100 | slowCallRateThreshold |
100 |
slowCallDurationThreshold |
60 s (las llamadas siempre se cronometran) | slowCallDuration |
0 = desactivado (las llamadas no se cronometran) |
slidingWindowType |
COUNT_BASED |
windowType |
COUNT |
slidingWindowSize |
100 | windowSize |
100 |
minimumNumberOfCalls |
100 | minimumCalls |
100 |
waitDurationInOpenState |
60 s | waitInOpen |
60 s |
permittedNumberOfCallsInHalfOpenState |
10 | halfOpenCalls |
10 |
maxWaitDurationInHalfOpenState |
0 | maxWaitInHalfOpen |
0 |
recordExceptions / recordException(predicate) |
todas las excepciones | recordOn (clases) o classifier |
Exception |
ignoreExceptions |
ninguna | ignoreOn |
ninguna |
automaticTransitionFromOpenToHalfOpenEnabled |
false |
sin equivalente: pasa a semiabierto en la siguiente llamada | — |
waitIntervalFunctionInOpenState, transitionOnResult, initialState, slidingWindowSynchronizationStrategy |
sin equivalente | — | |
writableStackTraceEnabled |
true |
no hace falta: los rechazos siempre son preasignados y sin stack trace | — |
Retry
| Resilience4j | Predeterminado | resilience4cata | Predeterminado |
|---|---|---|---|
maxAttempts |
3 | maxAttempts |
3 |
waitDuration |
500 ms, fijo | delay (+ backoff = FIXED, jitter = NONE para el mismo comportamiento) |
200 ms, exponencial, jitter completo |
intervalFunction(IntervalFunction.ofExponentialBackoff(…)) |
backoff = EXPONENTIAL, multiplier, maxDelay |
×2, máx. 10 s | |
IntervalFunction.ofRandomized(…), ofExponentialRandomBackoff(…) |
jitter = FULL o EQUAL |
FULL |
|
retryExceptions / retryOnException(predicate) |
todas las excepciones | retryOn o classifier |
Exception |
ignoreExceptions |
ninguna | abortOn |
— (un breaker abierto nunca se reintenta de todos modos) |
retryOnResult(predicate) |
classifier |
— | |
failAfterMaxAttempts |
false |
onExhaustedResult = THROW |
RETURN |
| sin equivalente | maxDuration (presupuesto de tiempo para todos los intentos) |
0 = ilimitado |
Rate limiter, timeout, bulkhead
| Resilience4j | Predeterminado | resilience4cata | Predeterminado |
|---|---|---|---|
RateLimiterConfig.limitForPeriod |
50 | @RateLimiter(permits) |
50 |
RateLimiterConfig.limitRefreshPeriod |
500 ns | @RateLimiter(period) |
1 s |
RateLimiterConfig.timeoutDuration |
5 s | @RateLimiter(maxWait) |
0 = rechaza de inmediato |
RateLimiterConfig.drainPermissionsOnResult |
sin equivalente | — | |
TimeLimiterConfig.timeoutDuration |
1 s | @Timeout(value) |
1 s |
TimeLimiterConfig.cancelRunningFuture |
true |
siempre: el stage de una llamada async se cancela si es un Future |
— |
BulkheadConfig.maxConcurrentCalls |
25 | @Bulkhead(maxConcurrent) |
25 |
BulkheadConfig.maxWaitDuration |
0 | @Bulkhead(maxWait) |
0 |
BulkheadConfig.fairCallHandlingStrategyEnabled |
true |
sin equivalente: el semáforo de espera no es justo | — |
Warning Tres valores predeterminados suelen sorprender: el retry de resilience4cata usa backoff exponencial con jitter completo desde 200 ms, no un valor fijo de 500 ms; su rate limiter rechaza de inmediato en lugar de esperar hasta 5 s; y su breaker no cronometra las llamadas a menos que configures
slowCallDuration. Configúralos explícitamente al migrar.
Hedge
El módulo resilience4j-hedge de Resilience4j (2.x) es programático y solo async, como el nuestro (sin anotación de Spring), y lanza como mucho un hedge.
| Resilience4j | resilience4cata | Notas |
|---|---|---|
HedgeConfig.Builder.preconfiguredDuration(d) |
@Hedge(delay = "…") |
El nuestro vale 1 s por defecto; "0" lanza todos los intentos a la vez |
averagePlusPercentageDuration(…), averagePlusAmountDuration(…) |
sin equivalente | Solo una espera fija; ponla cerca del p95 de la dependencia |
| un hedge, siempre | @Hedge(maxAttempts = 2) (el valor por defecto) |
Hasta 10 intentos |
withMaxConcurrency(n) (tamaño base del pool de threads del hedge) |
sin equivalente | Los hedges empiezan en virtual threads nuevos; el presupuesto de hedge limita su número en su lugar |
withContextPropagators(…) |
sin equivalente | Los intentos 2+ corren sin los ThreadLocal de quien llama |
submit(callable, executor) para código bloqueante |
sin equivalente | Los métodos sincrónicos son un error de compilación (R4C-HDG-001): devuelve un CompletableFuture |
HedgeOnPrimarySuccessEvent, …OnSecondarySuccessEvent, …Failure… |
HedgeEvent.Hedged, Won, Skipped, Failed |
Un primer intento que gana antes de la espera no publica nada |
La semántica difiere: en Resilience4j gana el primer intento que termina, así que una falla rápida de cualquiera de los dos hace fallar la llamada; en resilience4cata gana el primer éxito, una falla de hedgeOn lanza el siguiente intento enseguida, y la llamada solo falla cuando todos los intentos han fallado (o uno falla con una falla de abortOn). El perdedor se cancela, y el circuit breaker, el bulkhead y el timeout dentro del hedge liberan lo que tenía.
Diferencias de comportamiento
El orden
Fallback → Retry (o Hedge) → CircuitBreaker → RateLimiter → AdaptiveLimit → Timeout → Bulkhead → método por defecto, sin importar el orden de las anotaciones. Coincide con el orden predeterminado de aspectos de Spring en Resilience4j para las políticas que ambas tienen. Donde Resilience4j cambia el orden de forma global con las propiedades <module>AspectOrder, resilience4cata lo cambia por método, tipo o bundle con @PolicyOrder, al compilar; el fallback sigue siendo la capa más exterior.
Un breaker abierto detiene los retries
Un retry nunca reintenta CallNotPermittedException, diga lo que diga su abortOn, así que en cuanto el breaker se abre, el retry se rinde de inmediato en lugar de seguir reintentando contra él. En Resilience4j tienes que agregar tú mismo esa excepción a ignoreExceptions.
Los errores no se registran ni se reintentan de forma predeterminada
recordOn y retryOn tienen como valor predeterminado Exception: un Error (por ejemplo OutOfMemoryError, AssertionError) no se reintenta y cuenta como éxito para el breaker. @Fallback(applyOn) tiene como valor predeterminado Throwable, así que un fallback sí captura los Errors a menos que los agregues a skipOn.
Los timeouts interrumpen el thread de quien llama
Un @Timeout síncrono no mueve la llamada a otro thread: se ejecuta donde la hiciste, y un watchdog compartido la interrumpe cuando se acaba el tiempo. El código que ignora las interrupciones se ejecuta hasta terminar antes de que quien llama reciba CallTimeoutException. Los timeouts nunca se disparan antes de tiempo y como mucho llegan unos dos ticks del watchdog tarde (ticks de 1–50 ms). Consulta Timeout.
Los breakers pasan a semiabierto en la siguiente llamada
No hay ningún temporizador que pase un breaker abierto a semiabierto: lo hace la primera llamada después de waitInOpen. Un snapshot tomado entre medio sigue diciendo OPEN.
La configuración se resuelve una sola vez
Los valores de las anotaciones, las configs con nombre y la configuración externa se combinan cuando se crea la instancia y nunca cambian después. No hay changeConfig, ni evento de refresco, ni recarga en caliente. Los listeners también quedan fijos cuando se construye el registry.
Autoinvocación en el estilo de interfaz
Con Resilient.of, una llamada de tu implementación a uno de sus propios métodos se salta las políticas, como un proxy de Spring. Las clases anotadas (el estilo de subclase) sí interceptan this.method(). Consulta Clases anotadas.
Las instancias son por método salvo que les des nombre
Una instancia de Resilience4j la comparte todo lo que usa su nombre. En resilience4cata cada método anotado obtiene sus propias instancias, con un id como com.acme.StockClient#stock(java.lang.String); da el mismo name a varios métodos para que compartan una (sus configuraciones resueltas deben ser entonces idénticas, R4C-REG-008). @Timeout no tiene name.
El procesador es obligatorio
Sin el procesador de anotaciones no se genera nada y Resilient.of falla con R4C-RT-003. No hay alternativa por reflexión ni por proxy.
El mismo servicio, de las dos formas
Una consulta de stock con un retry (3 intentos, separados por 500 ms, solo ante IOException), un breaker compartido con el nombre inventory, un timeout de 2 s y un fallback.
Resilience4j, con decoradores:
CircuitBreaker breaker = CircuitBreaker.of("inventory", CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.slidingWindowSize(20)
.minimumNumberOfCalls(10)
.waitDurationInOpenState(Duration.ofSeconds(30))
.permittedNumberOfCallsInHalfOpenState(3)
.build());
Retry retry = Retry.of("inventory", RetryConfig.custom()
.maxAttempts(3)
.waitDuration(Duration.ofMillis(500))
.retryExceptions(IOException.class)
.ignoreExceptions(CallNotPermittedException.class)
.build());
TimeLimiter timeLimiter = TimeLimiter.of(Duration.ofSeconds(2));
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(4);
Supplier<CompletionStage<Integer>> decorated = Decorators
.ofCompletionStage(() -> CompletableFuture.supplyAsync(() -> client.stock(sku), scheduler)) // a blocking call moved to a thread
.withTimeLimiter(timeLimiter, scheduler)
.withCircuitBreaker(breaker)
.withRetry(retry, scheduler)
.withFallback(List.of(Exception.class), e -> -1)
.decorate();
int units = decorated.get().toCompletableFuture().join();
resilience4cata: las anotaciones en la interfaz,
public interface StockClient {
@Fallback(method = "unknownStock")
@Retry(name = "inventory", maxAttempts = 3, delay = "500ms", backoff = Backoff.FIXED, jitter = Jitter.NONE,
retryOn = IOException.class)
@CircuitBreaker(name = "inventory", failureRateThreshold = 50, windowSize = 20, minimumCalls = 10,
waitInOpen = "30s", halfOpenCalls = 3)
@Timeout("2s")
int stock(String sku) throws IOException;
default int unknownStock(String sku, Exception failure) {
return -1;
}
}
y el punto de llamada:
ResilienceRegistry registry = ResilienceTesting.registry(clock).build(); // ResilienceRegistry.builder() in production
StockClient stock = Resilient.of(StockClient.class, sku -> {
calls.incrementAndGet();
throw new IOException("inventory unreachable");
}, registry);
int units = stock.stock("SKU-1"); // three attempts 500 ms apart, then the fallback
La llamada síncrona sigue siendo síncrona: no hay executor, porque el timeout interrumpe el thread de quien llama en lugar de mover la llamada a otro.
Configuración externa. Las claves de application.yml de Spring Boot y sus equivalentes de resilience4cata en un archivo .properties (instances.inventory.* sobrescribe la anotación para esa instancia; defaults.* solo completa los atributos que la anotación no define):
# resilience4j.circuitbreaker.instances.inventory.sliding-window-size=50
instances.inventory.circuitBreaker.windowSize=50
# resilience4j.circuitbreaker.instances.inventory.wait-duration-in-open-state=10s
instances.inventory.circuitBreaker.waitInOpen=10s
# resilience4j.retry.instances.inventory.max-attempts=5
instances.inventory.retry.maxAttempts=5
# resilience4j.circuitbreaker.configs.default.failure-rate-threshold=40
defaults.circuitBreaker.failureRateThreshold=40
Cárgalo con ResilienceConfig.load() (como resilience4cata.properties en el classpath) o con ResilienceConfig.builder().classpath(…), y luego config.applyTo(ResilienceRegistry.builder()). Consulta Configuración.
Desde resilience4j-spring-boot3
resilience4cata-spring reemplaza a resilience4j-spring-boot3 en Spring Boot 4.1 o posterior
(guía de Spring Boot). Quita spring-boot-starter-aop: no se teje ni se envuelve nada con
proxies; el procesador de anotaciones genera las clases protegidas y la integración hace que Spring las instancie.
| resilience4j-spring-boot3 | resilience4cata-spring |
|---|---|
resilience4j.<module>.instances.<name>.<key> |
resilience4cata.instances.<name>.<policy>.<attribute> (circuit-breaker, retry, rate-limiter, timeout, bulkhead) |
resilience4j.<module>.configs.default.<key> |
resilience4cata.defaults.<policy>.<attribute> |
resilience4j.<module>.configs.<name>.<key> |
resilience4cata.configs.<name>.<policy>.<attribute>, usada por @Retry(config = "<name>") y similares |
baseConfig: <name> |
resilience4cata.configs.<config>.extends: <name>; una anotación elige su config con config = "<name>" |
propiedades <module>AspectOrder |
@PolicyOrder en el método, el tipo o un bundle (Cambiar el orden) |
beans CircuitBreakerConfigCustomizer, RetryConfigCustomizer… |
beans ResilienceRegistryCustomizer |
beans RegistryEventConsumer, getEventPublisher() |
beans EventListener<…> |
/actuator/circuitbreakers, /actuator/retries, /actuator/ratelimiters… |
/actuator/resilience4cata (todas las políticas, solo lectura) |
/actuator/circuitbreakerevents y los demás endpoints de eventos |
ninguno: no se guarda historial de eventos; usa beans listener o métricas |
indicadores de salud, desactivados por defecto (registerHealthIndicator) |
el indicador resilience4cata, activo pero UP con breakers abiertos salvo los listados en resilience4cata.spring.health.down-when-open |
métricas resilience4j.circuitbreaker.*… |
métricas r4c.* (Micrometer), con resilience4cata-micrometer |
fallbackMethod = "f" resuelto por reflexión |
@Fallback(method = "f"), comprobado al compilar |
Las mismas claves en application.yml:
# resilience4j.circuitbreaker.instances.inventory.sliding-window-size: 50
# resilience4j.circuitbreaker.configs.default.failure-rate-threshold: 40
# resilience4j.retry.instances.inventory.max-attempts: 5
resilience4cata:
defaults:
circuit-breaker:
failure-rate-threshold: 40
instances:
inventory:
circuit-breaker:
window-size: 50
retry:
max-attempts: 5
Las erratas detienen el arranque con el fichero y la línea de la clave, en lugar de ignorarse.
Paso a paso
- Agrega las dependencias, incluido el procesador en el processor path (Instalación); con Spring Boot,
resilience4cata-spring(más arriba). - Para cada llamada decorada, pon la llamada detrás de una interfaz (o anota la clase) y traduce la configuración con las tablas anteriores. Define
backoff,jitterymaxWaitexplícitamente donde los valores predeterminados difieren. - Reemplaza
Decorators/las anotaciones de Spring por las anotaciones de resilience4cata yResilient.of(oResilient.createpara clases anotadas). Las llamadas que no puedas poner detrás de una interfaz pasan por unPipeline. - Mueve los consumidores de eventos a
ResilienceRegistry.Builder.listen(…), y las métricas al módulo de Micrometer. - Reemplaza las pruebas basadas en tiempo por
ManualClock. - Compila con
-Xlint:all -Werrorsi quieres: el código generado compila sin advertencias.