Este artículo explica cómo usar
retryWhende Kotlin Flow para crear reintentos con un número de intentos garantizado. El patrón envuelve una llamada suspend de un solo disparo conflow { } → retryWhen { } → single()y separa éxito y fallo conResult.fold. Reúne el conocimiento previo que necesita un desarrollador que se acerca a Flow por primera vez para leer o escribir este patrón, y resume el comportamiento por escenario en diagramas.
Base: Kotlin 2.0.0, kotlinx.coroutines 1.8.1 (verificado en 2026-09). El código de la librería citado en el texto se copió del código fuente de la versión 1.8.1, y el ejemplo se compiló en esa versión y se ejecutó con 10 escenarios para comprobarlo.
Hay una función suspend que llama a una API HTTP externa. Si el fallo es transitorio (503, conexión cortada), se quiere volver a intentar. Las condiciones son tres.
Retry-AfterTambién se puede usar un while (true) con varias condiciones de salida. Pero basta con borrar por error una sola condición de salida para obtener un bucle que nunca termina. El patrón siguiente concentra la condición de terminación en un único lugar: la comparación del número de intentos.
Supongamos un servicio que llama a una API de pronóstico del tiempo. En lugar de un cliente HTTP real, se usa una interfaz.
data class Forecast(val city: String, val summary: String)
// Excepción que lanza el cliente al recibir una respuesta 4xx o 5xx
class HttpStatusException(val status: Int, val retryAfterSeconds: Long?) : RuntimeException("HTTP $status")
interface ForecastApi {
// Lanza IOException si no se recibe ninguna respuesta
suspend fun fetch(requestId: String, city: String): Forecast
}
data class RetryPolicy(val maxAttempts: Int) {
companion object {
val NO_RETRY = RetryPolicy(maxAttempts = 1)
}
}
sealed interface FetchResult {
val attempts: Int
data class Success(override val attempts: Int, val forecast: Forecast) : FetchResult
data class Failure(override val attempts: Int, val status: Int) : FetchResult
}
Si se reintenta y cuánto se espera se decide según el estado de la respuesta.
private const val MAX_RETRY_AFTER_SECONDS = 25L
// Devuelve los segundos de espera si se puede reintentar, o null si no
fun retryDelaySeconds(e: HttpStatusException): Long? {
return when (e.status) {
503 -> (e.retryAfterSeconds ?: 0L).coerceAtMost(MAX_RETRY_AFTER_SECONDS)
502 -> 0L
else -> null
}
}
Esta es la función principal.
class ForecastService(private val api: ForecastApi) {
suspend fun fetchForecast(city: String, policy: RetryPolicy): FetchResult {
var attempts = 0
return runCatchingCancellable {
flow {
attempts++
emit(api.fetch(requestId = "forecast:$city:$attempts", city = city))
}.retryWhen { cause, retriedCount ->
if (retriedCount + 1 >= policy.maxAttempts) {
return@retryWhen false
}
val delaySeconds = when (cause) {
is IOException -> 0L
is HttpStatusException -> retryDelaySeconds(cause)
else -> null
} ?: return@retryWhen false
delay(delaySeconds.seconds)
true
}.single()
}.fold(
onSuccess = { forecast -> FetchResult.Success(attempts = attempts, forecast = forecast) },
onFailure = { e ->
if (e !is HttpStatusException) {
throw e
}
FetchResult.Failure(attempts = attempts, status = e.status)
},
)
}
}
runCatchingCancellable no es una función de la biblioteca estándar. Es un helper que suele definirse a mano para evitar que el runCatching estándar se trague incluso CancellationException (kotlinx.coroutines#1814).
inline fun <R> runCatchingCancellable(block: () -> R): Result<R> {
return try {
Result.success(block())
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
Result.failure(e)
}
}
Hay dos puntos clave.
retryWhen. Si la función de decisión devuelve true, retryWhen vuelve a recolectar el flow de arriba desde el principio. Por eso el bloque flow { } se ejecuta una vez más.retriedCount + 1 >= maxAttempts, la función de decisión devuelve siempre false. Por eso el número de llamadas nunca supera maxAttempts.El builder flow { } no ejecuta el bloque de inmediato. Lo ejecuta cuando alguien recolecta (collect), y si se recolecta de nuevo, ejecuta el bloque otra vez desde el principio. Un flow así se llama cold flow.
val numbers = flow {
println("Inicio del bloque")
emit(1)
}
numbers.collect { println(it) } // "Inicio del bloque", 1
numbers.collect { println(it) } // "Inicio del bloque", 1 ← el bloque se ejecuta de nuevo
Esta propiedad es la base del reintento. retryWhen no rebobina la llamada fallida. Solo recolecta de nuevo el flow de arriba. Al recolectar de nuevo, el bloque vuelve a ejecutarse desde el principio y el api.fetch que contiene se llama otra vez. Por la misma razón sube attempts en el ejemplo. No es retryWhen quien incrementa el contador: al volver a ejecutarse el bloque, se ejecuta otra vez attempts++.
Los operadores se dividen en intermedios y terminales. Un operador intermedio como retryWhen solo crea y devuelve un nuevo flow, sin ejecutar nada. Hay que llamar a un operador terminal como single() o collect() para que se ponga en marcha toda la cadena. En el ejemplo, quien realmente inicia la llamada es el .single() del final de la cadena.
La implementación en kotlinx.coroutines 1.8.1 es corta. Se copia tal cual.
public fun <T> Flow<T>.retryWhen(predicate: suspend FlowCollector<T>.(cause: Throwable, attempt: Long) -> Boolean): Flow<T> =
flow {
var attempt = 0L
var shallRetry: Boolean
do {
shallRetry = false
val cause = catchImpl(this)
if (cause != null) {
if (predicate(cause, attempt)) {
shallRetry = true
attempt++
} else {
throw cause
}
}
} while (shallRetry)
}
El comportamiento se resume en cinco puntos.
predicate(cause, attempt); si devuelve true, se incrementa attempt en 1 y se vuelve a recolectarpredicate devuelve false, se lanza la excepción original cause tal cual, sin envolverlaattempt se cuenta desde 0 (vale 0 en el primer fallo)predicate es una función suspend, así que si espera con delay dentro y luego devuelve true, el resultado es "esperar y reintentar"Hay dos tipos de excepción que retryWhen no captura. El catchImpl interno las lanza tal cual.
single() o por el código del lado que recolectaCancellationException que se produce cuando se cancela una corrutinaPor eso, si se cancela la corrutina que llama, la cancelación se aplica de inmediato y no se intercepta como reintento. El runCatchingCancellable del ejemplo también vuelve a lanzar la excepción de cancelación, así que la cancelación nunca se convierte en un resultado Failure que quede tragado.
retry(retries) es un envoltorio delgado sobre retryWhen. En la implementación de 1.8.1 es una sola línea: retryWhen { cause, attempt -> attempt < retries && predicate(cause) }. El ejemplo usa retryWhen directamente porque el tiempo de espera cambia según el tipo de excepción y la respuesta.
El
attemptderetryWheny elretriesderetry(retries)son el número de reintentos. El número total de intentos es ese valor más 1. Por eso el ejemplo compararetriedCount + 1conmaxAttempts.
single() es un operador terminal. Recolecta el flow y devuelve un único valor. Según su KDoc, lanza NoSuchElementException si el flow está vacío e IllegalArgumentException si tiene dos o más elementos.
El bloque flow { } del ejemplo hace como máximo un emit por ejecución. Un intento fallido lanza la excepción antes del emit, así que no emite ningún valor. Por eso el valor que recibe single() es la respuesta del último intento exitoso. Si el flow de arriba termina finalmente con una excepción, single() también lanza esa excepción tal cual.
Result.fold(onSuccess, onFailure) llama a onSuccess si hay éxito y a onFailure si hay fallo, y devuelve el valor que retorna la lambda llamada. Ambas lambdas son inline, así que si se hace throw dentro de onFailure, la excepción sale de la función tal cual.
El ejemplo usa esta propiedad para dividir las excepciones en dos grupos.
| Excepción con la que falló el último intento | Significado | Resultado |
|---|---|---|
HttpStatusException |
El servidor respondió 4xx o 5xx | Se devuelve como Failure(status) |
IOException |
No se recibió respuesta | Se propaga la excepción |
| Cualquier otra excepción | Fallo inesperado | Se propaga la excepción |
CancellationException |
Cancelación de la corrutina | runCatchingCancellable la vuelve a lanzar y la cancelación sigue su curso |
Si hubo respuesta pero fue un fallo, quien llama lo recibe como resultado y ramifica. Si no hubo respuesta alguna, se avisa con una excepción.
El retriedCount que recibe la función de decisión se cuenta desde 0. attempts sube de 1 en 1 cada vez que se ejecuta el bloque flow { }. En el momento en que se llama a la función de decisión, siempre se cumple attempts = retriedCount + 1. Con maxAttempts = 2, el recorrido es el siguiente.
| Momento | attempts |
Final de requestId | retriedCount |
retriedCount + 1 >= 2 |
Resultado |
|---|---|---|---|---|---|
| Falla la 1.ª llamada | 1 | :1 |
0 | falso | Si es reintentable, se recolecta de nuevo |
| Falla la 2.ª llamada | 2 | :2 |
1 | verdadero | false: se lanza la excepción tal cual |
La 3.ª llamada no ocurre. La terminación la decide la comparación del número de intentos, no una condición de salida dentro de un bucle.
El requestId de las dos solicitudes es distinto: :1 y :2. Con este sufijo se pueden distinguir en los logs del servidor los reintentos de una misma llamada.
Un fallo con respuesta no vuelve como excepción aunque falle hasta el final, sino como resultado Failure. Si IOException ocurre dos veces, sigue la misma ruta, pero en fold se bifurca y la excepción se propaga.
Es una prueba escrita con JUnit 5 y AssertJ. runTest usa tiempo virtual, así que no espera realmente el delay(7 segundos). Si se mide el tiempo virtual transcurrido con testTimeSource, la prueba falla cuando falta la lógica de espera.
class ForecastServiceTest {
// API falsa que ejecuta en orden el comportamiento preparado para cada llamada
private class ScriptedApi(private val steps: List<() -> Forecast>) : ForecastApi {
private var calls = 0
override suspend fun fetch(requestId: String, city: String): Forecast {
return steps[calls++]()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `ante un 503 espera lo indicado por Retry-After, reintenta y tiene éxito`() = runTest {
// given: 1.º 503 (Retry-After 7), 2.º éxito
val forecast = Forecast("Seoul", "Soleado")
val api = ScriptedApi(listOf({ throw HttpStatusException(503, retryAfterSeconds = 7) }, { forecast }))
val startMark = testTimeSource.markNow()
// when
val result = ForecastService(api).fetchForecast("Seoul", RetryPolicy(maxAttempts = 2))
// then
assertThat(startMark.elapsedNow()).isEqualTo(7.seconds)
assertThat(result).isEqualTo(FetchResult.Success(attempts = 2, forecast = forecast))
}
}
testTimeSourceytestScheduler.currentTimeson@ExperimentalCoroutinesApien 1.8.1. Sin el opt-in, la compilación muestra una advertencia. Se añade solo a las pruebas que leen el tiempo virtual.
attempts sube cuando el bloque flow se ejecuta de nuevo. No es un valor que incremente retryWhenfalse, la excepción original sale tal cual. Como no se envuelve, en fold se puede separar el tipo con una comprobación isdelay dentro de la función de decisión. Es posible porque la función de decisión es suspendretryWhen no la captura y runCatchingCancellable la vuelve a lanzaremit de un valor dos veces, single() lanza IllegalArgumentException. Al modificar el bloque flow, hay que comprobar que solo haga un emitattempts se usa solo dentro de una llamada. Como la cadena se ejecuta de forma secuencial, no hay acceso concurrente. No se debe cambiar para que varias llamadas compartan la misma variableretry(n) { cause -> ... }: si el tiempo de espera es fijo y la decisión solo mira el tipo de excepción, esta opción es más corta. También se puede usar delay dentro de la lambda de decisiónfor (attempt in 1..maxAttempts): es más familiar para compañeros que no conocen Flow. Pero hace falta mantener aparte, fuera del bucle, una variable que devuelva el "último fallo"flow + retryWhen + single): la condición de terminación, la decisión de reintentar y la espera quedan reunidas en un solo lugar, la función de decisión