Cómo funciona
Un procesador de anotaciones convierte tus anotaciones en Java común al compilar; en ejecución, un ResilienceRegistry le da a ese código su configuración y su estado.
Qué genera el procesador
Por cada interfaz que usa una anotación de resiliencia, el procesador escribe una clase llamada <Interfaz>$Resilient en el mismo paquete. Implementa la interfaz, tiene un campo final de política por cada método protegido, y llama a tu implementación a través de las políticas con código lineal: un bucle para el retry, un bloque try para el breaker, sin lambdas, sin proxies y sin reflexión en el camino de la llamada.
public interface StockClient {
@Retry(maxAttempts = 3)
@CircuitBreaker
int stock(String sku, int warehouse);
}
public final class StockClient$Resilient implements StockClient {
private final StockClient delegate;
private final RetryPolicy retry0;
private final CircuitBreakerPolicy circuitBreaker0;
public StockClient$Resilient(StockClient delegate, ResilienceRegistry registry) {
this.delegate = Objects.requireNonNull(delegate, "delegate");
// …
this.retry0 = registry.retryPolicy("….StockClient#stock(java.lang.String,int)", "", "", r4c$b -> {
r4c$b.maxAttempts(3);
});
this.circuitBreaker0 = registry.circuitBreakerPolicy("….StockClient#stock(java.lang.String,int)", "", "", null);
}
@Override
public int stock(String p0, int p1) {
final RetryPolicy r4c$retry = this.retry0;
final long r4c$start = r4c$retry.start();
int r4c$attempts = 0;
while (true) {
try {
return this.r4c$circuitBreaker0(p0, p1);
} catch (Throwable r4c$e) {
final long r4c$delay = r4c$retry.onFailure(++r4c$attempts, r4c$e, r4c$start);
if (r4c$delay < 0 || !r4c$retry.pause(r4c$delay)) {
throw r4c$e;
}
}
}
}
private int r4c$circuitBreaker0(String p0, int p1) {
final 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 int r4c$result = this.delegate.stock(p0, p1);
r4c$cb.onSuccess(r4c$token, r4c$started);
return r4c$result;
} catch (Throwable r4c$e) {
r4c$cb.onFailure(r4c$token, r4c$started, r4c$e);
throw r4c$e;
}
}
}
Lo que se desprende de este diseño:
- Los errores son errores de compilación. Atributos desconocidos, duraciones inválidas, un fallback con la firma incorrecta, una anotación en un método
staticoprivate: el build falla con un código como[R4C-ATR-001]que tiene su propia sección en la referencia de errores. - El procesador es obligatorio. Si falta la clase generada,
Resilient.offalla de inmediato conR4C-RT-003, indicando la dependencia que hay que agregar. No hay alternativa por reflexión. - El código generado solo puede llamar a la API de
core, así quecoreyprocessordeben tener la misma versión (si no,R4C-RT-004; el BOM las mantiene alineadas). - El wrapper es thread-safe. Créalo una vez y compártelo, igual que el registry.
Measured Un retry cuyo primer intento tiene éxito cuesta 1.2 ns y no asigna memoria, contra 22.9 ns de Resilience4j 2.4.0 (AWS Graviton3
c7g.xlarge, un thread). Benchmarks
Dos estilos, más una API programática
| Estilo | Tú escribes | Lo obtienes con | Auto-invocación (this.other()) |
|---|---|---|---|
| Interfaz anotada | Anotaciones en una interfaz; cualquier implementación | Resilient.of(Type.class, impl, registry) |
No se intercepta: las llamadas de la implementación a sí misma no pasan por las políticas |
| Clase anotada | Anotaciones en una clase no final | Resilient.create(Type.class, registry, constructorArgs...) |
Se intercepta: la subclase generada sobrescribe el método |
Pipeline |
Sin anotaciones: un builder | Pipeline.builder(registry, id, Type.class)…build() |
n/a |
Con el estilo de interfaz solo cuentan las anotaciones de la interfaz. Una clase que implementa una interfaz anotada pero no declara ninguna anotación propia no se procesa; envuélvela con Resilient.of. Una clase que declara anotaciones se procesa con el estilo de clase.
El registry y los ids de instancia
ResilienceRegistry guarda las configuraciones (valores por defecto, configuraciones con nombre, overrides por instancia), los listeners de eventos y cada instancia de política con estado. Constrúyelo una vez con ResilienceRegistry.builder()…build(); después es inmutable.
Cada método protegido recibe sus propias instancias de política, bajo un id de instancia:
- el atributo
namede la política, si lo tiene (@CircuitBreaker(name = "prices")→prices); los métodos, pipelines y wrappers que usan el mismo nombre comparten una instancia (una ventana de breaker, un conjunto de permisos), y deben resolver la misma configuración (si no,R4C-REG-008); - si no, el id derivado
<tipo calificado>#<método>(<tipos de parámetros>), con los tipos de los parámetros completamente calificados, borrados y separados por comas sin espacios:com.acme.StockClient#stock(java.lang.String,int). Los tipos anidados usan.(com.acme.Repository.Cache#get(java.lang.String)). En una clase anotada, el tipo es la clase.
@Timeout no tiene name: su id es siempre el derivado. @Fallback no guarda estado y no tiene handle ni configuración.
El id es cómo encuentras una instancia en ejecución (registry.circuitBreaker(id), rateLimiter(id), bulkhead(id)), cómo la nombran los eventos y los snapshots, y cómo la ajusta la configuración externa (instances.<id>.…). Este test envuelve StockClient y busca su breaker por el id derivado:
AtomicInteger calls = new AtomicInteger();
StockClient stock = Resilient.of(StockClient.class, (sku, warehouse) -> {
if (calls.incrementAndGet() == 1) {
throw new IllegalStateException("warehouse busy");
}
return 7;
}, registry);
int count = stock.stock("sku-1", 3); // the first attempt fails, the retry succeeds
CircuitBreakerSnapshot breaker = registry.circuitBreaker(
"dev.resilience4cata.examples.docs.howitworks.StockClient#stock(java.lang.String,int)").snapshot();
// breaker.bufferedCalls() == 2 and breaker.failedCalls() == 1: the breaker saw both attempts
De dónde viene la configuración
Cada atributo de anotación tiene un valor "sin definir", así que una anotación solo sobrescribe lo que escribe; los valores por defecto reales viven en el registry. Los valores se resuelven en este orden, de menor a mayor precedencia:
- Los valores por defecto incorporados (para
@Retry: 3 intentos, backoff exponencial de 200 ms con jitter completo; cada página de política los lista). - Los valores por defecto del registry:
retryDefaults(...)y similares, odefaults.<policy>.*en la configuración externa. - La configuración con nombre elegida con
config = "…": registrada en código conretry("external", ...), externamente conconfigs.external.*. - Las anotaciones: los bundles por debajo de las anotaciones directas, el tipo por debajo del método (bundles).
- Los overrides por instancia:
retryInstance(id, ...)y similares, oinstances.<id>.*externamente.
Todo se resuelve una vez, al crear el wrapper, la subclase o el pipeline, en records inmutables; las llamadas nunca consultan la configuración. No hay recarga en caliente: cambiar un valor implica construir un registry nuevo, en la práctica reiniciar la aplicación.
ResilienceRegistry registry = ResilienceRegistry.builder()
.retryDefaults(r -> r.delay(Duration.ofMillis(50))) // every retry
.circuitBreakerInstance(
"dev.resilience4cata.examples.docs.howitworks.StockClient#stock(java.lang.String,int)",
cb -> cb.minimumCalls(20)) // one instance
.build();
StockClient stock = Resilient.of(StockClient.class, (sku, warehouse) -> 7, registry); // resolved here
Un nombre de configuración desconocido falla en ese momento (R4C-REG-004), no en la primera llamada.
Orden de las políticas
Sin importar en qué orden escribas las anotaciones, las políticas se anidan en este orden por defecto, de afuera hacia adentro (@PolicyOrder lo cambia):
flowchart LR
caller([caller]) --> F[Fallback] --> R[Retry | Hedge] --> CB[CircuitBreaker] --> RL[RateLimiter] --> AL[AdaptiveLimit] --> T[Timeout] --> B[Bulkhead] --> M([your method])
- El fallback es el más externo, así que ve el resultado final, incluidos los rechazos como un breaker abierto o un bulkhead lleno.
- Cada intento del retry pasa por el breaker, que lo registra (el ejemplo de arriba muestra dos llamadas registradas para una llamada reintentada), y un breaker abierto detiene los reintentos:
@Retrynunca reintenta unaCallNotPermittedException. - Las llamadas que rechaza el breaker no consumen permisos del rate limiter.
- El timeout se aplica a cada intento;
@Retry(maxDuration = …)limita la operación completa. - El slot del bulkhead se ocupa solo mientras corre el método, no durante el backoff del retry.
El orden todavía no es configurable. La introducción a las políticas explica cómo interactúan.