Saltar al contenido
K
La lista de PokémonPaso a paso21 min de lectura · 4 min de máquina Intermedio

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».

ejemplo mínimo: reintentar con espera creciente (con comentarios)
// 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 manzanas

exponentialDelay(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

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/network/PokeApiClient.ktse modifica: reintentos y expectSuccess
shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/data/network/ReintentosTest.ktse crea
Un fichero que se modifica y uno de pruebas que se crea.

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:

PokeApiClient.kt: imports nuevos
import io.ktor.client.plugins.HttpRequestRetry    // el plugin de reintentos
import kotlinx.coroutines.CancellationException   // para no reintentar lo cancelado

Y dentro de createHttpClient, en la lambda configuracion, el bloque de reintentos, justo debajo del install(ContentNegotiation) { … }:

PokeApiClient.kt: el bloque de reintentos
        // 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):

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/data/network/ReintentosTest.kt
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):

PokeApiClient.kt: primera línea de la configuración
    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 = true

Ejecuta los tests de las tres plataformas:

./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test

Ahora 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:

  • unErrorDelServidorSeReintentaYAcabaComoServidor sigue fallando con Inesperado(…): expectSuccess = true falta o está fuera de la lambda configuracion.
  • Unresolved reference 'IOException' (o cualquier otra palabra en rojo): falta un import. 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:iosSimulatorArm64Test termina en BUILD SUCCESSFUL en 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 de ReintentosTest en cada destino. Los «ejercicios» de las lecciones suman tests (los de los que hayas hecho), y la JVM tiene uno más que Android porque ModulesTest solo corre en la JVM.
  • Un 503 llega a AppError.Servidor(503) tras tres intentos, y un 404 a AppError.Servidor(404) tras uno solo.
  • PokeApiClient.kt empieza la configuración con expectSuccess = true e instala HttpRequestRetry justo después de ContentNegotiation.

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.El cliente instala HttpRequestRetry con retryOnServerErrors(maxRetries = 2). ¿Por qué no se reintentan también los 4xx?

  2. 2.¿Qué aporta exponentialDelay frente a reintentar tres veces seguidas?

  3. 3.Con el cliente de Ktor sin expectSuccess = true, el servidor contesta 503 y la app intenta leer el cuerpo como PokemonPageDto. ¿Qué AppError sale?

  4. 4.Al escribir ReintentosTest sin expectSuccess, el test del 503 falla con Inesperado(…), pero la comprobación assertEquals(3, llamadas) anterior pasa. ¿Qué dice eso?

Faltan 4 de 4