Resiliencia: timeouts y reintentos con coroutines y Reactor
Una dependencia lenta es una dependencia que falla: acota cada llamada suspend con un timeout y luego reinténtala con backoff.
En cualquier sistema que habla con la red, el modo de fallo más peligroso no es el error que vuelve rápido: es la llamada que nunca vuelve. Un servicio aguas abajo que se cuelga bloqueará en silencio uno de tus hilos o coroutines, agotará tu pool de conexiones y derivará en una caÃda total. La primera regla de la resiliencia es, por tanto, poner un lÃmite superior estricto al tiempo que se permite a cualquier operación de I/O. En las coroutines de Kotlin ese lÃmite se expresa con `withTimeout` y `withTimeoutOrNull`, dos primitivas de concurrencia estructurada que convierten 'esto podrÃa colgarse para siempre' en 'esto termina, de un modo u otro, dentro de N milisegundos.'
`withTimeout(duration) { ... }` ejecuta el bloque y, si no ha terminado cuando salta el temporizador, lo cancela y lanza `TimeoutCancellationException` (subclase de `CancellationException`). Como la cancelación es cooperativa, el bloque debe contener un punto de suspensión —una llamada suspend real como una petición HTTP o `delay`— para que el timeout surta efecto; un bucle de CPU sin suspensión no puede interrumpirse. Su hermana `withTimeoutOrNull(duration) { ... }` usa la misma maquinaria pero devuelve `null` en lugar de lanzar, que es la opción idiomática cuando un resultado vencido es simplemente 'sin valor' y no una condición excepcional que quieras propagar. Prefiere las sobrecargas con `Duration` (`2.seconds`) antes que los millis crudos por legibilidad.
Un detalle sutil pero importante: `TimeoutCancellationException` es una `CancellationException`, y la concurrencia estructurada trata la cancelación de forma especial. Si envuelves un bloque `withTimeout` en un `try/catch (e: Exception)` ingenuo, te tragarás el timeout y, peor aún, podrÃas tragarte la cancelación legÃtima del scope padre. Captura `TimeoutCancellationException` de forma explÃcita cuando quieras reaccionar a un timeout, y nunca captures un `CancellationException` pelado sin relanzarlo. Asà tu coroutine se comporta bien dentro del árbol de cancelación.
Los timeouts hacen que una llamada falle rápido; los reintentos dan a un fallo transitorio una segunda oportunidad. El instinto ingenuo es un bucle con retardo fijo, pero los reintentos constantes martillean un servicio ya en apuros y sincronizan a todos los clientes en oleadas de estampida. La cura estándar es el backoff exponencial: cada intento sucesivo espera aproximadamente el doble que el anterior, con un máximo. Añade jitter —un pequeño componente aleatorio sumado a cada retardo— para que mil clientes que fallaron en el mismo instante no reintenten todos en el mismo instante. El resultado es un cliente educado y autorregulado que se aparta justo cuando el sistema necesita respirar.
Un helper de reintentos de nivel producción necesita más que un contador y un retardo. Debe decidir *qué* fallos son reintentables: un 503 o un reset de conexión merecen reintento, pero un 400 Bad Request o un error de validación fallarán igual cada vez y deben propagarse de inmediato. También debe respetar la cancelación: un bucle de reintento que captura `Throwable` atrapará la `CancellationException` y seguirá iterando tras cancelarse su scope, un bug clásico de coroutines. Relanza siempre la `CancellationException` antes de decidir si reintentar, y condiciona los reintentos a un predicado para repetir solo operaciones que puedan plausiblemente tener éxito más tarde.
En un código Spring Boot 3 a menudo vives entre dos mundos: funciones suspend en tu capa de servicio y un `WebClient` reactivo que devuelve `Mono`/`Flux` por debajo. En el lado de Reactor, el equivalente a un reintento con backoff exponencial es el combinador declarativo `Retry.backoff(maxAttempts, minBackoff)` pasado a `retryWhen`. Soporta `.maxBackoff(...)`, `.jitter(...)` y `.filter { it is SomeRetryableException }`, reflejando todo lo que construirÃas a mano en coroutines. Y es clave: puedes aplicar el timeout de Reactor (`.timeout(Duration)`) y `retryWhen` sobre el `Mono`, y luego puentear todo el pipeline resiliente a tu mundo suspend con `.awaitSingle()` de `kotlinx-coroutines-reactor`, de modo que el llamador ve una única llamada suspend limpia.
Al combinar ambas técnicas, el orden importa. El timeout debe envolver cada intento individual, no toda la secuencia de reintentos; de lo contrario un único timeout global podrÃa expirar en mitad del backoff y perderÃas el beneficio de reintentar. La forma correcta es: el bucle de reintento por fuera y el timeout por intento por dentro, para que cada intento tenga su propio plazo fresco. Una sutileza: como `withTimeout` lanza `TimeoutCancellationException` —que es a su vez una `CancellationException`— un helper de reintentos que relanza obedientemente la cancelación nunca reintentará un timeout. Cuando *quieras* que un intento vencido se reintente, usa `withTimeoutOrNull` dentro del bloque y convierte el `null` en una excepción reintentable normal (por ejemplo `java.util.concurrent.TimeoutException`). Asà cada intento falla rápido si se cuelga, el bucle espera, y el siguiente arranca con todo su presupuesto de timeout. Si además necesitas un techo global de tiempo total de reloj, exprésalo como un `withTimeout` externo alrededor de todo el bucle, obteniendo una garantÃa a dos niveles: latencia por intento acotada y latencia total acotada.
Por último, trata estas primitivas como bloques de construcción, no como una estrategia de resiliencia completa. Timeouts y reintentos combinan de forma natural con un circuit breaker (para dejar de reintentar un servicio claramente caÃdo), un bulkhead (para limitar las llamadas concurrentes en vuelo) y claves de idempotencia (porque reintentar una escritura no idempotente puede cobrarle dos veces a un cliente). LibrerÃas como Resilience4j empaquetan estos patrones con soporte de primera clase para coroutines y Reactor. Pero el modelo mental que construyes aquà —acota la latencia de cada llamada, reintenta solo fallos transitorios, espera con jitter y respeta la cancelación— es la base sobre la que se construye toda herramienta de más alto nivel.
import kotlinx.coroutines.*import kotlin.time.Duration.Companion.millisecondsimport kotlin.time.Duration.Companion.secondssuspend fun slowFetch(): String {delay(2.seconds) // simulate a slow downstream callreturn "payload"}fun main() = runBlocking {// Throwing variant: react to the timeout explicitly.val result = try {withTimeout(500.milliseconds) { slowFetch() }} catch (e: TimeoutCancellationException) {"fell back after timeout"}println(result) // -> fell back after timeout// Null variant: a timed-out result is just 'no value'.val maybe: String? = withTimeoutOrNull(500.milliseconds) { slowFetch() }println(maybe ?: "no value") // -> no value}
withTimeout throws on expiry; withTimeoutOrNull returns null. Note the timeout only fires at a suspension point (here, delay).
Arena IDEimport kotlinx.coroutines.*import kotlin.random.Randomimport kotlin.time.Durationimport kotlin.time.Duration.Companion.millisecondsimport kotlin.time.Duration.Companion.secondssuspend fun <T> retryWithBackoff(maxAttempts: Int = 4,initialDelay: Duration = 100.milliseconds,maxDelay: Duration = 5.seconds,factor: Double = 2.0,retryOn: (Throwable) -> Boolean = { true },block: suspend () -> T,): T {var currentDelay = initialDelayrepeat(maxAttempts - 1) { attempt ->try {return block()} catch (e: CancellationException) {throw e // never swallow cancellation} catch (e: Throwable) {if (!retryOn(e)) throw e // non-retryable -> propagate nowval jitter = Random.nextLong(0, 50).millisecondsdelay(currentDelay + jitter)val next = (currentDelay.inWholeMilliseconds * factor).toLong()currentDelay = minOf(next.milliseconds, maxDelay)}}return block() // last attempt: let it throw}fun main() = runBlocking {var calls = 0val value = retryWithBackoff(retryOn = { it is IllegalStateException }) {calls++if (calls < 3) error("transient failure #$calls")"ok on attempt $calls"}println(value) // -> ok on attempt 3}
Generic exponential-backoff retry with jitter. It rethrows CancellationException (so the loop respects cancellation) and only retries failures matching the predicate.
Arena IDEimport kotlinx.coroutines.withTimeoutOrNullimport org.springframework.stereotype.Serviceimport org.springframework.web.reactive.function.client.WebClientimport org.springframework.web.reactive.function.client.awaitBodyimport java.util.concurrent.TimeoutExceptionimport kotlin.time.Duration.Companion.seconds@Serviceclass PricingClient(private val webClient: WebClient) {suspend fun fetchPrice(sku: String): Price =retryWithBackoff(maxAttempts = 3,retryOn = { it is TimeoutException || it is java.io.IOException },) {// Timeout wraps EACH attempt -> fresh budget per retry.// withTimeoutOrNull turns a timeout into a plain (retryable)// exception instead of a CancellationException the helper rethrows.withTimeoutOrNull(2.seconds) {webClient.get().uri("/prices/{sku}", sku).retrieve().awaitBody<Price>() // bridges Mono -> suspend} ?: throw TimeoutException("pricing call timed out for $sku")}}data class Price(val sku: String, val amount: Long, val currency: String)
Spring Boot 3 service: a per-attempt timeout INSIDE the retry loop so every try gets a fresh deadline. Uses withTimeoutOrNull + a thrown TimeoutException so the timeout stays retryable (a raw TimeoutCancellationException would be rethrown as cancellation by retryWithBackoff). Not runnable here (needs Spring WebClient + coroutines).
import kotlinx.coroutines.reactor.awaitSingleimport org.springframework.web.reactive.function.client.WebClientimport org.springframework.web.reactive.function.client.bodyToMonoimport reactor.util.retry.Retryimport java.time.Durationsuspend fun WebClient.fetchUser(id: String): User =get().uri("/users/{id}", id).retrieve().bodyToMono<User>().timeout(Duration.ofSeconds(2)) // per-subscription deadline.retryWhen(Retry.backoff(3, Duration.ofMillis(200)) // exponential backoff.maxBackoff(Duration.ofSeconds(5)).jitter(0.5) // 50% randomization.filter { it !is IllegalArgumentException }).awaitSingle() // Mono -> suspenddata class User(val id: String, val name: String)
Pure Reactor interop: declarative timeout + Retry.backoff, then awaitSingle() to expose one clean suspend call. Needs reactor-core + kotlinx-coroutines-reactor.
🧠Comprueba tu comprensión
0/1 · 0/1 answered1. When combining a per-attempt timeout with an exponential-backoff retry loop, why should the timeout be placed INSIDE the retry loop rather than wrapping the entire loop?