Métodos async
Un método que devuelve CompletionStage<T> o CompletableFuture<T> recibe la forma no bloqueante de cada política: ningún thread duerme, espera ni queda estacionado por él.
No hay una anotación @Asynchronous: decide el tipo de retorno. Se aplican las mismas anotaciones, atributos y claves de configuración.
Una interfaz async
public interface TrackingClient {
@Retry(maxAttempts = 3, delay = "50ms")
@Fallback(method = "unknown")
CompletableFuture<String> track(String parcel);
default CompletionStage<String> unknown(String parcel, IOException failure) {
return CompletableFuture.completedFuture("unknown (" + failure.getMessage() + ")");
}
@Timeout("100ms")
CompletionStage<String> eta(String parcel);
}
ResilienceRegistry registry = ResilienceRegistry.builder().build();
TrackingClient tracking = Resilient.of(TrackingClient.class, http, registry);
CompletableFuture<String> status = tracking.track("p-1"); // returns at once: no thread sleeps between attempts
// status completes with "in transit" after two failed attempts
La llamada devuelve un future de inmediato. El primer intento corre en el thread de quien llama; cuando falla, el retry programa el siguiente intento en un timer después de su delay, y ese intento empieza en un thread virtual nuevo. Ningún thread queda bloqueado durante el delay.
Qué cambia respecto de un método sincrónico
| Política | Método sincrónico | Método async |
|---|---|---|
| Retry | Duerme en el thread de quien llama entre intentos | Programa el siguiente intento; el future devuelto se completa cuando un intento tiene éxito o el retry se rinde |
| Hedge | No soportado (error de compilación R4C-HDG-001) |
Vuelve a lanzar la llamada tras delay sin resultado, o enseguida tras una falla; el primer éxito completa el future y los demás intentos se cancelan |
| Circuit breaker | Registra la llamada cuando devuelve o lanza | Registra la llamada cuando su stage se completa |
Rate limiter con maxWait |
Duerme hasta que el permiso esté disponible | Empieza la llamada cuando el permiso está disponible, sin bloquear a quien llama |
| Adaptive limit | Conserva el lugar mientras el método corre, y mide su latencia | Conserva el lugar hasta que el stage se completa, y mide la latencia hasta esa finalización |
| Timeout | Interrumpe el thread de quien llama al vencer el plazo | Hace fallar el future devuelto con CallTimeoutException al vencer el plazo, y cancela el stage tardío si es un Future (a través de las políticas internas, que sueltan lo que tienen de inmediato: ver cancelación) |
| Bulkhead | Ocupa el lugar mientras corre el método; con maxWait, espera en un semáforo |
Ocupa el lugar hasta que el stage se completa; con maxWait, encola la llamada sin bloquear un thread |
| Fallback | Devuelve un valor | Devuelve un valor o un stage |
Reglas que valen para todas las políticas:
- Un método que lanza una excepción en lugar de devolver un stage se trata como un stage fallido: las políticas ven la excepción y, si nada la recupera, el future devuelto falla con ella. Un método que devuelve
nullen lugar de un stage falla de la misma forma con unaNullPointerException. - Las fallas se desenvuelven. Un stage que falla con
CompletionExceptionoExecutionExceptionse juzga por su causa, así queretryOn,recordOn, los classifiers y las firmas de los fallbacks ven tu excepción (IOExceptionen el ejemplo), no el envoltorio. - Los rechazos hacen fallar el future. Un breaker abierto, un bulkhead lleno, un adaptive limit lleno o un rate limiter agotado hacen fallar el future devuelto con
CallNotPermittedException,BulkheadFullException,LimitExceededExceptionoRateLimitExceededException. - Los callbacks no corren en el timer. Los reintentos diferidos, los hedges, las esperas del rate limiter y los timeouts se disparan en un timer de la librería, que pasa el trabajo a un thread virtual nuevo. Por eso los intentos posteriores al primero (un reintento, un hedge) corren sin los
ThreadLocalni el MDC de quien llama.
Una llamada que vence su timeout, del mismo test:
CompletableFuture<String> eta = tracking.eta("p-1").toCompletableFuture();
assertThatThrownBy(() -> eta.get(5, TimeUnit.SECONDS))
.isInstanceOf(ExecutionException.class)
.hasCauseInstanceOf(CallTimeoutException.class); // after 100 ms; no thread was blocked
Cuando haces join() o get() sobre el future, el JDK envuelve la falla: join() lanza CompletionException y get() lanza ExecutionException, con la excepción de la política como causa.
Cancelación
Cancelar el future que devuelve un método async (con cancel(...), o con completeExceptionally y una CancellationException) cancela la llamada que hay dentro, a través de todas las capas: cada política pasa la cancelación al stage que está esperando, hasta llegar al stage que devolvió tu método, que se cancela si es un Future. Qué hace cada política:
| Política | Al cancelar |
|---|---|
| Retry | No empieza más intentos: un backoff pendiente se cancela, y el intento en curso también |
| Hedge | No empieza más intentos: la espera pendiente se cancela, y todos los intentos en curso también. Un hedge cancela también a sus propios perdedores de la misma forma, con una HedgeCancelledException |
| Circuit breaker | No registra nada, como una llamada ignorada: un permiso de half-open se devuelve |
Rate limiter con maxWait |
Una llamada que todavía espera su permiso nunca empieza, y el permiso se devuelve (como cuando se interrumpe una espera sincrónica). Una llamada que ya empezó conserva su permiso |
| Adaptive limit | Libera el lugar en cuanto el stage termina, sin muestra: una llamada cancelada no dice nada de la dependencia |
| Timeout | Cancela su plazo: no hay CallTimeoutException ni evento TimedOut |
| Bulkhead | Libera el lugar en cuanto termina el stage, que para un CompletableFuture es de inmediato. Una llamada todavía encolada (maxWait) sale de la cola y nunca empieza |
| Fallback | No corre: nadie espera un resultado alternativo |
Una cancelación no publica eventos: ni GaveUp, ni Retry, ni falla, ni rechazo. Las capas internas ya soltaron lo que tenían antes de que corran los callbacks que adjuntaste al future, así que un callback puede empezar una llamada nueva en el lugar que se liberó.
Para tener en cuenta:
- La cancelación llega al stage, no al trabajo detrás de él. Cancelar un
CompletableFuturelo completa; no interrumpe el código que lo habría completado. El bulkhead libera su lugar cuando termina el stage, así que si tu stage no es unFuture, o ignoracancel, el lugar sigue ocupado (y el breaker registra el resultado tardío) hasta que el stage se complete. - Cancela el future que devolvió el método. Un future derivado de él (
thenApply,whenComplete,copy()y similares) es unCompletableFuturenuevo: cancelarlo no llega a la llamada, como en el JDK. - La cancelación y la finalización compiten de forma segura. Si la llamada termina en el mismo momento, gana la que llegue primero; los lugares y permisos se liberan exactamente una vez en cualquier caso.
Pipeline.executeAsyncse comporta igual.
Fallbacks
Un fallback hermano de un método async devuelve el tipo del valor T o un CompletionStage<T> (en el ejemplo, unknown devuelve un stage). Una clase handler implementa FallbackHandler<T> o FallbackHandler<CompletionStage<T>>. El fallback corre cuando el retry se rinde, y también ante rechazos y timeouts, igual que su forma sincrónica. Ver fallback.
Stages con wildcard
El tipo de retorno puede ser CompletionStage<? extends X> o CompletableFuture<? extends X>; ? solo significa ? extends Object. Las políticas trabajan entonces con X: los fallbacks devuelven un X (o un stage de uno), y los classifiers deben aceptar X.
public interface ParcelClient {
@Retry(maxAttempts = 2, delay = "10ms")
@Fallback(method = "noWeight")
CompletionStage<? extends Number> weight(String parcel);
default Integer noWeight(String parcel, Exception failure) {
return 0;
}
}
Qué no está soportado
- Otros tipos de stage son errores de compilación (
R4C-ASY-001):CompletionStagecrudo,? super X, y los subtipos deCompletionStagedistintos deCompletableFuture. - Los tipos reactivos son errores de compilación (
R4C-ASY-002):Flow.Publishery elPublisherde Reactive Streams (MonoyFluxde Reactor),UniyMultide Mutiny, y los tipos de RxJava. Solo construyen un pipeline que se ejecuta al suscribirse, así que las políticas protegerían su construcción, no el trabajo. Convierte a unCompletionStageen la implementación (uni.subscribeAsCompletionStage(),mono.toFuture()). - Otros tipos asíncronos se tratan como sincrónicos. Un método que devuelve
java.util.concurrent.Futureo cualquier otra cosa se protege como cualquier método sincrónico: las políticas ven solo la llamada que devuelve el objeto, no el trabajo que representa.
Warning Si un método devuelve un
Future, ni el timeout ni el retry se aplican al resultado. Devuelve unCompletableFuture, o envuelve el trabajo en uno.
Costo
Las llamadas async asignan memoria: cada capa crea futures y pasa por callbacks de CompletableFuture, que es el precio de no bloquear threads. Las llamadas sincrónicas no se ven afectadas.
Measured Un timeout async con un stage pendiente cuesta 152 ns y 192 B en un thread, contra 598 ns y 240 B de Resilience4j 2.4.0 (AWS Graviton3
c7g.xlarge). Benchmarks