Quarkus
Usa resilience4cata en una aplicación Quarkus: el código generado protege los beans anotados, el registro se
configura desde application.properties, y SmallRye Health y Micrometer lo ven.
La extensión resilience4cata-quarkus (disponible desde 0.5.0) es una extensión de Quarkus normal, con un módulo de
ejecución y uno de despliegue. No añade nada a la ruta de la llamada: no hay interceptores de CDI. Sus pasos de
construcción hacen que las clases generadas por el procesador de anotaciones sean los beans que recibe tu
aplicación, así que una llamada cuesta lo mismo que sin Quarkus, más el proxy de cliente de ArC cuando el bean es de
ámbito normal.
Requisitos: Quarkus 3.40 o posterior (la extensión se compila y se prueba con 3.40.1, la línea LTS actual) y Java 21.
Instalación
Añade la extensión y, como para cualquier uso de las anotaciones, el procesador de anotaciones
(instalación). La extensión trae resilience4cata-core y resilience4cata-config;
Quarkus encuentra su módulo de despliegue por sí solo.
Gradle
dependencies {
implementation enforcedPlatform("io.quarkus.platform:quarkus-bom:${quarkusPlatformVersion}")
implementation platform('dev.resilience4cata:resilience4cata-bom:0.10.0')
annotationProcessor platform('dev.resilience4cata:resilience4cata-bom:0.10.0')
implementation 'dev.resilience4cata:resilience4cata-quarkus'
compileOnly 'dev.resilience4cata:resilience4cata-annotations'
annotationProcessor 'dev.resilience4cata:resilience4cata-processor'
implementation 'dev.resilience4cata:resilience4cata-micrometer' // opcional: métricas, con quarkus-micrometer
testImplementation 'dev.resilience4cata:resilience4cata-testkit' // opcional: ManualClock
}
Gradle (Kotlin)
dependencies {
implementation(enforcedPlatform("io.quarkus.platform:quarkus-bom:${quarkusPlatformVersion}"))
val r4cBom = platform("dev.resilience4cata:resilience4cata-bom:0.10.0")
implementation(r4cBom)
annotationProcessor(r4cBom)
implementation("dev.resilience4cata:resilience4cata-quarkus")
compileOnly("dev.resilience4cata:resilience4cata-annotations")
annotationProcessor("dev.resilience4cata:resilience4cata-processor")
implementation("dev.resilience4cata:resilience4cata-micrometer") // opcional: métricas, con quarkus-micrometer
testImplementation("dev.resilience4cata:resilience4cata-testkit") // opcional: ManualClock
}
Maven
<!-- con resilience4cata-bom importado en <dependencyManagement>, junto al BOM de Quarkus -->
<dependency>
<groupId>dev.resilience4cata</groupId>
<artifactId>resilience4cata-quarkus</artifactId>
</dependency>
<dependency>
<groupId>dev.resilience4cata</groupId>
<artifactId>resilience4cata-annotations</artifactId>
<scope>provided</scope>
</dependency>
<!-- en la configuración de maven-compiler-plugin -->
<annotationProcessorPaths>
<path>
<groupId>dev.resilience4cata</groupId>
<artifactId>resilience4cata-processor</artifactId>
<version>0.10.0</version>
</path>
<!-- annotationProcessorPaths sustituye al descubrimiento: lista también los demás procesadores que uses -->
</annotationProcessorPaths>
Sin el procesador de anotaciones no se genera nada y los beans quedan sin protección: las anotaciones tienen retención
CLASS, así que nada en ejecución puede notar que están ahí. Con el registro de depuración de la extensión (quarkus.log.category."dev.resilience4cata".level=DEBUG) la construcción lista cada bean que conecta.
Las clases generadas se buscan en el índice de la aplicación, como los beans: un jar de biblioteca con clases
anotadas necesita un índice de Jandex (o quarkus.index-dependency), igual que para sus beans.
Clases anotadas
Anota la propia clase del bean:
@ApplicationScoped
public class OrderService {
@Inject
OrderRepository orders;
@Retry(maxAttempts = 3, delay = "100ms")
@CircuitBreaker(name = "orders")
public Order find(String id) {
return orders.load(id);
}
public Order findAgain(String id) {
return find(id); // a call on this: still retried
}
}
El procesador genera OrderService$Resilient extends OrderService. La extensión veta OrderService y convierte
OrderService$Resilient en el bean en su lugar, con el ámbito, los calificadores y los estereotipos de la clase.
Así:
@Inject OrderServicerecibe la clase generada (detrás del proxy de cliente de ArC, como cualquier bean@ApplicationScoped); los calificadores,@Named,@Alternative/@Priorityy@Startupsiguen funcionando;- las llamadas sobre
this(findAgainllamando afind) pasan por las políticas, a diferencia de los interceptores de CDI como los de SmallRye Fault Tolerance; - la inyección por campo, los métodos inicializadores,
@PostConstructy los métodos observadores se heredan; la inyección por constructor también funciona: los constructores generados reciben elResilienceRegistryseguido de los parámetros originales, con sus anotaciones (clases anotadas).
Las clases de ámbito normal necesitan un constructor sin parámetros. A los beans @ApplicationScoped,
@RequestScoped y @SessionScoped se llega a través de un proxy de cliente, que necesita un constructor no privado
sin parámetros. Quarkus añade uno a una clase que no lo tiene, pero no a la subclase generada de una clase que no lo
tiene, así que la construcción falla con R4C-QK-005. Usa inyección por campo como
arriba, añade un constructor package-private sin parámetros (solo lo usa el proxy), o haz el bean @Singleton.
Una clase con métodos o campos @Produces no puede sustituirse (los productores no se heredan):
R4C-QK-004.
Interceptores sobre la clase. Los interceptores de CDI (@Transactional, @Logged, …) los aplica una subclase
de ArC de la clase generada, así que se ejecutan fuera de las políticas. Para las transacciones eso significaría una
sola transacción para todos los intentos, y después de que un intento fallido la marque rollback-only, un intento
posterior exitoso no puede hacer commit. Por eso un método que es a la vez reintentado o con hedge y
@Transactional (con un tipo que se une a una transacción o la inicia) detiene la construcción con
R4C-QK-002. Declara la política en una interfaz (una transacción por intento, ver
abajo), mueve @Transactional a otro bean al que llame el método, o pon
quarkus.resilience4cata.allow-retry-inside-transaction=true si lo que quieres es una sola transacción compartida.
Interfaces anotadas
@Retry(maxAttempts = 3)
@Timeout("2s")
public interface PaymentsGateway {
String charge(String order, long cents);
}
@ApplicationScoped
public class CardPayments implements PaymentsGateway {
@Override
public String charge(String order, long cents) {
return "receipt-" + order; // a call to the payment provider in a real application
}
}
Para cada bean que implementa una interfaz anotada, la extensión registra un bean sintético con los tipos de la
interfaz y los calificadores del bean, creado por Resilient.of(PaymentsGateway.class, bean, registry). Es una
alternativa habilitada con prioridad alta, así que gana dondequiera que se inyecte la interfaz:
@ApplicationScoped
public class Checkout {
@Inject
PaymentsGateway payments; // the interface: the wrapper PaymentsGateway$Resilient around CardPayments
public String pay(String order) {
return payments.charge(order, 1999);
}
}
@Inject CardPayments(la clase) sigue recibiendo el propio bean, sin protección.- El envoltorio de un bean
@ApplicationScopedtambién es@ApplicationScoped, detrás de su propio proxy de cliente, así que la interfaz sigue siendo un bean de ámbito normal (un@InjectMocksobre la interfaz en un test funciona, y reemplaza al envoltorio, políticas incluidas). El envoltorio de un bean de ámbito de petición o de sesión es un@Singletonque guarda el proxy de cliente del bean (esos ámbitos siguen funcionando). Ambos se crean al arrancar; el envoltorio de un bean@Dependenttambién es@Dependent. - Los interceptores de CDI del bean se ejecutan dentro de las políticas: un método
@Transactionalreintentado obtiene una transacción por intento. - Las llamadas sobre
thisdentro deCardPaymentsno están protegidas (el envoltorio está fuera del objeto). - Un bean que implementa dos interfaces anotadas, sin que una extienda a la otra, detiene la construcción con
R4C-QK-003. Los métodos productores no se envuelven: devuelve tú mismoResilient.of(...)desde ellos.
Clientes REST
Anota una interfaz de cliente REST como cualquier otra:
@RegisterRestClient(configKey = "stock")
@Path("/stock")
@Retry(maxAttempts = 3, delay = "50ms")
public interface StockClient {
@GET
@Path("/{sku}")
int available(@PathParam("sku") String sku);
}
El bean del cliente REST tiene el calificador @RestClient. La extensión añade un bean @Default a su alrededor,
así que inyectar la interfaz sin calificador da el cliente protegido, y @RestClient sigue dando el original:
@ApplicationScoped
public class StockReport {
@Inject
StockClient stock; // no @RestClient: the protected client
public String line(String sku) {
return sku + ": " + stock.available(sku);
}
}
Configuración
Las políticas leen la configuración de la aplicación bajo resilience4cata. (o r4c.), con las claves de la
referencia de configuración en camelCase o kebab-case:
resilience4cata.defaults.retry.delay=50ms
resilience4cata.defaults.circuit-breaker.minimum-calls=20
resilience4cata.configs.slow.retry.delay=500ms
resilience4cata.instances.orders.circuit-breaker.failure-rate-threshold=25
resilience4cata.instances."dev.resilience4cata.quarkus.guide.PaymentsGateway#charge(java.lang.String,long)".timeout.value=3s
%dev.resilience4cata.defaults.retry.max-attempts=2
- Los valores vienen de SmallRye Config, así que funciona cualquier fuente de Quarkus:
application.properties, YAML (quarkus-config-yaml),.env, propiedades del sistema,-Dy expresiones (${…}). - Los perfiles son los de Quarkus:
%dev.,%test.,%prod.y los tuyos. Las claves propias de resilience4cataprofiles.<nombre>.*siguen funcionando también. - La precedencia es la de Quarkus: propiedades del sistema sobre variables de entorno sobre
application.properties. Las variables de entorno usan los nombresR4C_oRESILIENCE4CATA_deresilience4cata-config(SmallRye no puede convertirlos de vuelta en claves camelCase). Un ficheroresilience4cata.propertiesen el classpath se sigue leyendo, por debajo de todos ellos. - Los ids de instancia que no sean solo letras, dígitos,
_y-(el derivado<tipo>#<método>(<tipos de parámetros>)) van entre comillas dobles, como arriba. - La configuración se lee una vez, al arrancar, como en todo resilience4cata: no hay recarga.
- Las claves bajo
quarkus.resilience4cata.son los interruptores propios de la extensión.
Validación al arrancar. El registro se crea cuando arranca la aplicación, así que una clave desconocida o un valor incorrecto la detiene con todos los problemas a la vez, y el fichero y la línea donde se puso cada clave:
ERROR [io.quarkus.runtime.Application] Failed to start application
dev.resilience4cata.config.ConfigException: Invalid resilience4cata configuration (1 problem):
[R4C-CFG-001] unknown key 'resilience4cata.defaults.retry.maxAtempts' (application.properties:2). Did you mean 'resilience4cata.defaults.retry.maxAttempts'? ...
Al arrancar también se comprueban las claves resilience4cata.instances.<id>.* frente a las instancias de
políticas creadas hasta ese momento (R4C-CFG-013). Quarkus crea los beans de forma
perezosa, así que por defecto es una advertencia (quarkus.resilience4cata.verify-instances=warn): los envoltorios
de las interfaces se crean al arrancar, pero las clases anotadas solo cuando se usan por primera vez, salvo que sean
beans @Startup. Pon fail cuando todas las clases protegidas se crean al arrancar.
Customizers y listeners
Un bean ResilienceRegistryCustomizer ajusta el builder del registro, por ejemplo una configuración con nombre en
código:
@Singleton
public class ResilienceSetup implements ResilienceRegistryCustomizer {
@Override
public void customize(ResilienceRegistry.Builder builder) {
builder.retry("slow", retry -> retry.maxAttempts(5).delay(Duration.ofSeconds(1)));
}
}
Los customizers se ejecutan en orden de @Priority, antes de las propiedades, así que las propiedades ganan:
resilience4cata.configs.slow.retry.delay de arriba sustituye el retraso del customizer y mantiene su
maxAttempts.
Cada bean EventListener se registra para el tipo de evento de sus tipos de bean (un listener sin tipo recibe
todos los eventos):
@Produces
@Singleton
EventListener<CircuitBreakerEvent.StateTransition> breakerAlerts(Alerts alerts) {
return transition -> alerts.send(transition.instance() + " is now " + transition.to());
}
Un bean Ticker (el ManualClock del testkit) pasa a ser el reloj del registro. Una aplicación que produce su
propio bean ResilienceRegistry sustituye al de la extensión (entonces no se aplican customizers, listeners ni
métricas); el bean ResilienceConfig sigue ahí para construirlo con config.applyTo(builder).
Métricas y salud
- Métricas: con
quarkus-micrometer(y un registro comoquarkus-micrometer-registry-prometheus) yresilience4cata-micrometer, los medidores de Micrometer se registran solos y aparecen en/q/metrics. Sin Micrometer pero conquarkus-opentelemetryyresilience4cata-opentelemetry, se registran en su lugar las métricas de OpenTelemetry (se prefiere Micrometer cuando están los dos, como hace SmallRye Fault Tolerance). Las llamadas exitosas no publican eventos, así que no cuestan nada. - Salud: con
quarkus-smallrye-health, la comprobación de preparaciónresilience4cata(/q/health/ready) informa del estado y la tasa de fallos de cada circuit breaker. Se mantiene UP con breakers abiertos: un breaker abierto es la aplicación protegiéndose de una dependencia que falla, no un motivo para sacarla del balanceo de carga. Lista los breakers que deben ponerla en DOWN enquarkus.resilience4cata.health.down-when-open(*para todos). - Todavía no hay tarjeta en la Dev UI ni endpoint:
registry.snapshot()lo tiene todo, si quieres exponerlo.
Dev UI
En modo dev (quarkus dev, o ./gradlew quarkusDev), la Dev UI en /q/dev-ui tiene una tarjeta de
resilience4cata. Su página Policies lista cada instancia de política que el registry creó hasta el momento, y se
actualiza cada dos segundos mientras la página está visible:
| Política | Qué muestra la página |
|---|---|
| Circuit breakers | estado, tasas de fallas y de llamadas lentas, llamadas en la ventana (fallidas, lentas) |
| Bulkheads | llamadas concurrentes contra el máximo, llamadas en espera |
| Rate limiters | permisos disponibles contra los permisos por período |
| Adaptive limits | algoritmo, límite actual y sus cotas, llamadas en curso, latencia de la última ventana y latencia de largo plazo |
| Retries, hedges | intentos máximos (y espera), las llamadas del presupuesto y los reintentos o hedges permitidos, cuando hay presupuesto |
| Timeouts | el timeout |
La mayoría de las instancias se crean en la primera llamada a su método (las de una clase anotada, al crearse el
bean), así que una política aparece una vez que se usó. Un valor que todavía no tiene sentido, como una tasa de fallas
antes de minimumCalls, se muestra como un guion.
La página también puede forzar la apertura de un circuit breaker, deshabilitarlo o reiniciarlo, como hace
registry.circuitBreaker(id): útil para ver cómo se comporta la aplicación con una dependencia caída. El enlace
Guide de la tarjeta abre esta sección.
Nada de esto existe fuera del modo dev: los build steps que agregan la tarjeta y su servicio JsonRPC corren solo en desarrollo local, así que un build de tests o de producción no tiene ninguno de los dos.
SmallRye Fault Tolerance frente a resilience4cata
Quarkus trae su propia tolerancia a fallos, SmallRye Fault Tolerance
(quarkus-smallrye-fault-tolerance, MicroProfile Fault Tolerance). Las dos funcionan en la misma aplicación, pero
no en el mismo método.
Elige SmallRye Fault Tolerance cuando quieras la opción integrada y estándar: portabilidad de MicroProfile,
@Asynchronous y tipos de retorno de Mutiny, configuración que se puede cambiar en ejecución, y nada que añadir a
la construcción.
Elige resilience4cata cuando quieras que el mismo código funcione fuera de CDI (Java simple, pruebas sin
contenedor, Spring Boot), anotaciones y configuración comprobadas al compilar y al arrancar con errores con código,
configuraciones con nombre, bundles, ajustes por instancia, clasificadores y hedging, llamadas sobre this
protegidas en clases anotadas, o su sobrecoste.
Mecanismo. SmallRye Fault Tolerance es un interceptor de CDI; resilience4cata es código generado que la
extensión convierte en el bean. Ninguno protege un new CardPayments() que no haya creado CDI.
Imports. Las dos bibliotecas llaman a sus anotaciones @Retry, @CircuitBreaker, @Timeout, @Bulkhead y
@Fallback, y los IDE importan la primera coincidencia. Un método (o clase) que lleva anotaciones de las dos detiene
la construcción con R4C-QK-001 cuando SmallRye Fault Tolerance está instalado (una
advertencia si no). Prefiere una biblioteca por módulo, o un bundle como @ExternalCall para que los
nombres simples no aparezcan.
Equivalencias. Los valores por defecto también difieren: consulta la referencia de configuración.
| SmallRye / MicroProfile Fault Tolerance | resilience4cata |
|---|---|
@Retry(maxRetries = 3) (reintentos tras la primera llamada) |
@Retry(maxAttempts = 4) (intentos, el primero incluido) |
@Retry(delay = 200, delayUnit = ChronoUnit.MILLIS) |
@Retry(delay = "200ms", backoff = Backoff.FIXED) (el backoff por defecto es exponencial) |
@Retry(jitter = 200) (± una duración) |
@Retry(jitter = Jitter.FULL) (NONE, FULL o EQUAL, una parte de cada retraso) |
@Retry(maxDuration = 180000) |
@Retry(maxDuration = "3m") |
@Retry(retryOn = …, abortOn = …) |
@Retry(retryOn = …, abortOn = …) |
@ExponentialBackoff |
@Retry(backoff = Backoff.EXPONENTIAL, multiplier = 2, maxDelay = "10s") |
@CircuitBreaker(requestVolumeThreshold = 20, failureRatio = 0.5) |
@CircuitBreaker(windowSize = 20, minimumCalls = 20, failureRateThreshold = 50) (un porcentaje) |
@CircuitBreaker(delay = 5000) |
@CircuitBreaker(waitInOpen = "5s") |
@CircuitBreaker(successThreshold = 1) |
@CircuitBreaker(halfOpenCalls = 1) |
@CircuitBreaker(failOn = …, skipOn = …) |
@CircuitBreaker(recordOn = …, ignoreOn = …) |
@Timeout(value = 2, unit = ChronoUnit.SECONDS) |
@Timeout("2s") |
@Bulkhead(10) |
@Bulkhead(maxConcurrent = 10) |
@Bulkhead(value = 10, waitingTaskQueue = 10) con @Asynchronous |
@Bulkhead(maxConcurrent = 10, maxWait = "…") (espera un tiempo, síncrono o asíncrono) |
@RateLimit(value = 100, window = 1, windowUnit = ChronoUnit.SECONDS) |
@RateLimiter(permits = 100, period = "1s") |
@Fallback(fallbackMethod = "cached") |
@Fallback(method = "cached") |
@Fallback(MyHandler.class) |
@Fallback(MyHandler.class) (un FallbackHandler) |
@Asynchronous con CompletionStage |
basta con un tipo de retorno CompletionStage/CompletableFuture |
quarkus.fault-tolerance."<id>".retry.max-retries=5 |
resilience4cata.instances.<id>.retry.maxAttempts=6 |
quarkus.fault-tolerance.global.retry.max-retries=5 |
resilience4cata.defaults.retry.maxAttempts=6 |
Cambiar un valor en ejecución, que SmallRye permite, no tiene equivalente: resilience4cata resuelve la configuración una sola vez, por diseño.
Interruptores
| Propiedad | Fase | Por defecto | Efecto |
|---|---|---|---|
quarkus.resilience4cata.beans.enabled |
construcción | true |
Convertir las clases generadas en los beans (las subclases sustituyen a las clases anotadas, los envoltorios representan a los beans de interfaces) |
quarkus.resilience4cata.beans.exclude |
construcción | vacío | Clases de beans (con nombre completo) que se dejan como están |
quarkus.resilience4cata.allow-retry-inside-transaction |
construcción | false |
Permitir @Retry/@Hedge en métodos @Transactional de clases anotadas en lugar de fallar con R4C-QK-002 |
quarkus.resilience4cata.health.enabled |
construcción | true |
La comprobación de preparación, cuando SmallRye Health está presente |
quarkus.resilience4cata.metrics.enabled |
construcción | true |
Vincular el registro a Micrometer u OpenTelemetry |
quarkus.resilience4cata.verify-instances |
ejecución | warn |
fail, warn u off para claves de instancia que no coinciden con nada al arrancar |
quarkus.resilience4cata.health.down-when-open |
ejecución | vacío | Ids de breakers (o *) que ponen la comprobación en DOWN mientras están abiertos |
Pruebas
Produce un bean ManualClock en tus fuentes de prueba: las pausas de los reintentos, las esperas del bulkhead y del
rate limiter y los timeouts lo siguen, así que una prueba nunca duerme (guía de pruebas).
@Singleton
public class TestClock {
@Produces
@Singleton
ManualClock clock() {
return new ManualClock(); // retry pauses, waits and timeouts follow it instead of the system clock
}
}
@Inject
OrderService orders;
@Inject
ManualClock clock;
@Inject
OrderRepository repository; // fails the first loads it is told to
@Test
void retriesWithoutSleeping() {
repository.failNext(2);
long before = clock.elapsed().toNanos();
assertThat(orders.find("42")).isEqualTo(new Order("42"));
assertThat(clock.elapsed().toNanos()).isGreaterThan(before); // the two pauses, on the manual clock
}
Un bean de ámbito normal se inyecta como proxy de cliente: lee y cambia su estado con métodos, nunca a través de sus campos.
Imágenes nativas
El procesador escribe metadatos de alcanzabilidad de GraalVM para cada clase generada, y core inicializa sus hilos
en segundo plano en ejecución, así que una construcción nativa (-Dquarkus.native.enabled=true) no necesita
configuración adicional: el código generado por ArC instancia las clases anotadas, y Resilient.of los envoltorios
de interfaces, que encuentra gracias a esos metadatos. La configuración se lee en ejecución. La CI del proyecto no
construye imágenes nativas; el registro de cambios indica qué se comprobó a mano.
Límites
- Modo dev: todavía no se ha comprobado si una recarga en caliente vuelve a ejecutar el procesador de anotaciones. Si un cambio en las anotaciones parece ignorarse, reinicia el modo dev.
- Las clases anotadas de ámbito normal necesitan un constructor sin parámetros (o inyección por campo); las clases anotadas no pueden declarar productores.
- Envoltorios de interfaces: las llamadas sobre
thisdentro de la implementación no están protegidas; una interfaz anotada por bean; los métodos productores no se envuelven. - Con clases anotadas, los interceptores de CDI como
@Transactionalse ejecutan fuera de las políticas; un método transaccional reintentado o con hedge detiene la construcción (R4C-QK-002) salvo que se permita. - Sin tarjeta en la Dev UI, sin endpoint, sin recarga en caliente de la configuración, sin métricas por llamada.