Reintentos con Ktor
Al terminar: Reintentar de verdad cuando el servidor falla, con espera creciente y probado con tests.
La red falla a ratos, y casi siempre se arregla sola: un túnel, un servidor que se reinicia, un Wi-Fi que parpadea. Al terminar esta lección el cliente de PokeAPI volverá a intentar lo que merece la pena, esperando un poco más cada vez, y se rendirá enseguida con lo que no tiene arreglo. Además, un fallo del servidor llegará por fin como AppError.Servidor, que hasta ahora no se producía nunca.
Es una lección corta, solo de commonMain y commonTest: no se instala nada.
Las rutas son las de la vista Project, la que enseña las carpetas tal como están en el disco. Si el panel de la izquierda todavía dice «Android», cámbialo con el selector de arriba del panel.
Qué se reintenta, y cuánto se espera
Qué. Ya conoces los códigos de estado: los 4xx dicen que la petición no puede cumplirse y los 5xx que el servidor tiene un problema (Red: HTTP y JSON en Android y Red: HTTP, JSON y URLSession en iOS). Reintentar un 404 no sirve de nada: seguirá sin existir. Reintentar un 503 sí puede servir: el servidor quizá se levante en dos segundos. Y los fallos de red (sin conexión, un corte) también merecen otra oportunidad.
Cuánto. Es como volver a llamar a un teléfono que da ocupado: si llamas otra vez al instante, sigue ocupado; si esperas un poco y luego más, tienes más suerte. Eso es el retroceso exponencial: la espera crece en cada intento. Y se le añade un poco de azar, para que mil móviles que han fallado a la vez no vuelvan a llamar todos en el mismo segundo.
Por defecto, Ktor no reintenta nada. Tiene un plugin para ello (un plugin es una pieza que se instala en el cliente con install(…), como ya hiciste con la lectura de JSON y los tiempos máximos), y la propia documentación lo dice:
Un ejemplo, con una tienda
Antes de tocar el cliente de la app, el plugin solo, con datos que no tienen nada que ver con el proyecto: un servidor de una tienda que contesta 503 las dos primeras veces y 200 a la tercera. Es un ejemplo para leer, no lo escribas. Está dentro de un test con runTest, así que las esperas son de reloj virtual y no hacen lento el test; testScheduler.currentTime dice cuántos milisegundos de ese reloj han pasado. Esa función es experimental en la librería de pruebas, y Kotlin pide que lo reconozcas con una anotación, @OptIn(ExperimentalCoroutinesApi::class): significa «sé que esto puede cambiar en una versión futura y lo uso igualmente».
// Reconocer que se usa algo experimental (`testScheduler.currentTime`).
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun reintentaConEsperaCreciente() = runTest {
var intento = 0
// Un servidor de mentira: las dos primeras veces contesta 503 («no disponible»), la tercera 200.
val motor = MockEngine {
intento++
println("intento $intento, en el segundo ${testScheduler.currentTime / 1000.0}")
if (intento < 3) respond("cerrado", HttpStatusCode.ServiceUnavailable)
else respond("3 manzanas", HttpStatusCode.OK)
}
val cliente = HttpClient(motor) {
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = 2) // hasta 2 reintentos si el servidor da 5xx
exponentialDelay(base = 2.0, maxDelayMs = 4_000) // espera creciente entre intentos
}
}
val respuesta = cliente.get("https://tienda.example/stock")
println("respuesta: ${respuesta.status.value} ${respuesta.bodyAsText()}")
}Salida real. Los segundos cambian de una ejecución a otra, por el azar:
intento 1, en el segundo 0.0
intento 2, en el segundo 1.802
intento 3, en el segundo 4.509
respuesta: 200 3 manzanasexponentialDelay(base = 2.0) hace que la espera se multiplique por 2 en cada intento: unos 1 s antes del primer reintento y unos 2 s antes del segundo, y a cada una se le suma hasta 1 s de azar (por eso salen 1,8 y 2,7 s, y no 1 y 2 exactos). El cliente intentó tres veces en total (la inicial y dos reintentos), esperó algo más de un segundo antes del segundo intento y bastante más antes del tercero, y a quien llamó a get le llegó el 200 final como si nada hubiera pasado.
Paso 1 · El reintento en el cliente
El archivo. PokeApiClient.kt, el que ya tienes en shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/. Aquí se configura el cliente HTTP, y aquí va a vivir el reintento automático. El plugin HttpRequestRetry viene dentro de ktor-client-core, la librería que ya instalaste, así que no hay nada que añadir al catálogo.
Primero, el import del plugin y el de CancellationException, que se usa dentro. Van con los demás imports del fichero:
import io.ktor.client.plugins.HttpRequestRetry // el plugin de reintentos
import kotlinx.coroutines.CancellationException // para no reintentar lo canceladoY dentro de createHttpClient, en la lambda configuracion, el bloque de reintentos, justo debajo del install(ContentNegotiation) { … }:
// Reintenta lo que merece la pena: fallos de red y errores del servidor.
// Un 404 no se reintenta, porque va a seguir siendo 404.
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = 2) // un 5xx: hasta 2 reintentos más
// Cualquier excepción de red, MENOS la cancelación: reintentar algo que
// el usuario acaba de cancelar es trabajar en su contra.
// `{ _, causa -> … }` recibe la petición y la excepción; el `_` es el
// parámetro que no se usa (la petición).
retryOnExceptionIf(maxRetries = 2) { _, causa -> causa !is CancellationException }
// La espera crece entre intentos (1 s, luego 2 s, más un poco de azar):
// tres peticiones seguidas contra un servidor caído no ayudan a que se
// levante. Tope: 4 segundos por espera.
exponentialDelay(base = 2.0, maxDelayMs = 4_000)
}Tres decisiones, cada una con su porqué: los 5xx se reintentan y los 4xx no; la cancelación se excluye también aquí (el mismo motivo que en aAppError); y la espera crece.
Paso 2 · Los tests de los reintentos
ReintentosTest usa MockEngine para comprobar el cliente que acabas de configurar: cuántas veces llama y qué error sale. Créalo: clic derecho sobre shared → New → File → src/commonTest/kotlin/com/sebastiannarvaez/pokedex/data/network/ReintentosTest.kt (la carpeta network ya existe: la creó KtorPokeApiTest):
package com.sebastiannarvaez.pokedex.data.network
import com.sebastiannarvaez.pokedex.core.AppConfig
import com.sebastiannarvaez.pokedex.core.AppError
import com.sebastiannarvaez.pokedex.core.aAppError
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import kotlinx.coroutines.test.runTest
import kotlinx.io.IOException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
// Una configuración de mentira: solo hace falta la dirección base.
private class ConfigDePrueba : AppConfig {
override val pokeApiBaseUrl = "https://pokeapi.co/api/v2/"
}
class ReintentosTest {
// Las cabeceras de una respuesta que dicen «el cuerpo es JSON». Sin ellas, el
// cliente no sabría leer el cuerpo como JSON.
private val json = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
@Test
fun unErrorDelServidorSeReintentaYAcabaComoServidor() = runTest {
var llamadas = 0
val motor = MockEngine { // este motor contesta siempre 503
llamadas++
respond("{}", HttpStatusCode.ServiceUnavailable, json)
}
val api = KtorPokeApi(createHttpClient(ConfigDePrueba(), motor))
// `assertFailsWith` comprueba que el bloque lanza una excepción y, además, la
// devuelve: así se puede mirar qué error fue.
val fallo = assertFailsWith<Throwable> { api.page(limit = 20, offset = 0) }
assertEquals(3, llamadas) // el intento inicial más 2 reintentos
assertEquals(AppError.Servidor(503), fallo.aAppError())
}
@Test
fun unNoEncontradoNoSeReintenta() = runTest {
var llamadas = 0
val motor = MockEngine {
llamadas++
respond("{}", HttpStatusCode.NotFound, json)
}
val api = KtorPokeApi(createHttpClient(ConfigDePrueba(), motor))
val fallo = assertFailsWith<Throwable> { api.detail(id = 99999) }
assertEquals(1, llamadas) // un 404 no se vuelve a pedir
assertEquals(AppError.Servidor(404), fallo.aAppError())
}
@Test
fun unaRedQueSeRecuperaTerminaBien() = runTest {
var llamadas = 0
val motor = MockEngine {
llamadas++
if (llamadas < 3) throw IOException("socket cerrado") // falla dos veces…
respond("""{"count": 0, "results": []}""", HttpStatusCode.OK, json) // …y a la tercera va
}
val api = KtorPokeApi(createHttpClient(ConfigDePrueba(), motor))
val pagina = api.page(limit = 20, offset = 0)
assertEquals(3, llamadas)
assertEquals(0, pagina.count)
}
}runTest adelanta el reloj sin esperar, así que la espera creciente entre intentos no hace lenta la prueba. En un teléfono de verdad, tres intentos contra un servidor caído tardan entre tres y cinco segundos en rendirse: una espera de entre 1 y 2 s antes del primer reintento y de entre 2 y 3 s antes del segundo (el 1 s y el 2 s de la configuración, más hasta 1 s de azar en cada una).
Ejecútalo: ./gradlew :shared:jvmTest. Fallan dos tests. La Terminal solo da el nombre y la línea (java.lang.AssertionError at ReintentosTest.kt:30 el del 503 y …:47 el del 404); el mensaje de cada uno está en el informe (open shared/build/reports/tests/jvmTest/index.html, clase ReintentosTest). El del 503 dice:
java.lang.AssertionError: expected:<Servidor(codigo=503)> but was:<Inesperado(causa=Illegal input: Field 'count' is required for type with serial name 'com.sebastiannarvaez.pokedex.data.network.dto.PokemonPageDto', but it was missing at path: $)>Fíjate en lo que no falla: la línea anterior, assertEquals(3, llamadas), pasa. El reintento funciona, el cliente llamó tres veces. Lo que sale mal es el error final: no es Servidor(503), es Inesperado, con un mensaje de «formato» que no habla del servidor. Y el del 404 es igual, con Fields [id, name] are required….
Paso 3 · expectSuccess: que un 503 sea un error
El concepto. Por defecto, Ktor entrega cualquier respuesta como buena, también un 404 o un 503, y deja que quien la lee decida. KtorPokeApi la lee como un PokemonPageDto, el cuerpo {} no tiene los campos que debería, y el fallo salta ahí, al leer el cuerpo, con un mensaje sobre el formato. La rama ResponseException de aAppError nunca se ejecuta y AppError.Servidor es inalcanzable.
expectSuccess = true le dice al cliente que solo espera respuestas buenas: ante un 4xx lanza ClientRequestException y ante un 5xx, ServerResponseException, que son las dos ResponseException, la que escribiste en aAppError. Es una línea, la primera de la lambda configuracion, antes de install(ContentNegotiation):
val configuracion: HttpClientConfig<*>.() -> Unit = { // esta línea ya existe
// Sin esto, un 404 o un 503 NO lanzan nada: Ktor entrega la respuesta
// como si fuera buena y el fallo salta después, al leer el cuerpo, con
// un error de «formato» que no dice nada del servidor.
expectSuccess = trueEjecuta los tests de las tres plataformas:
./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64TestAhora sí: BUILD SUCCESSFUL. Un 503 llega a AppError.Servidor(503) tras tres intentos, y un 404 a AppError.Servidor(404) tras uno solo (el retryOnServerErrors no toca los 4xx). Dos fallos típicos:
unErrorDelServidorSeReintentaYAcabaComoServidorsigue fallando conInesperado(…):expectSuccess = truefalta o está fuera de la lambdaconfiguracion.Unresolved reference 'IOException'(o cualquier otra palabra en rojo): falta unimport. Sitúa el cursor sobre la palabra y pulsa Option+Enter: el IDE ofrece añadirlo.
Punto de control
./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Testtermina enBUILD SUCCESSFULen las tres plataformas, con 33 / 32 / 32 tests (JVM / Android host / iOS) con tus ejercicios, o 27 / 26 / 26 sin ellos: a los de antes se suman los 3 deReintentosTesten cada destino. Los «ejercicios» de las lecciones suman tests (los de los que hayas hecho), y la JVM tiene uno más que Android porqueModulesTestsolo corre en la JVM.- Un 503 llega a
AppError.Servidor(503)tras tres intentos, y un 404 aAppError.Servidor(404)tras uno solo. PokeApiClient.ktempieza la configuración conexpectSuccess = truee instalaHttpRequestRetryjusto después deContentNegotiation.
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.El cliente instala
HttpRequestRetryconretryOnServerErrors(maxRetries = 2). ¿Por qué no se reintentan también los 4xx?2.¿Qué aporta
exponentialDelayfrente a reintentar tres veces seguidas?3.Con el cliente de Ktor sin
expectSuccess = true, el servidor contesta 503 y la app intenta leer el cuerpo comoPokemonPageDto. ¿QuéAppErrorsale?4.Al escribir
ReintentosTestsinexpectSuccess, el test del 503 falla conInesperado(…), pero la comprobaciónassertEquals(3, llamadas)anterior pasa. ¿Qué dice eso?