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

Errores que se pueden contar

Al terminar: Convertir un fallo de red en un mensaje útil que la interfaz pueda enseñar.

La lista ya sabe fallar: PokemonPagingSource devuelve LoadResult.Error cuando el repositorio lanza. Lo que no sabe todavía es qué decirle al usuario, ni si tiene sentido ofrecerle un botón de reintentar.

Al terminar esta lección, cualquier excepción que salga de la red se habrá convertido en dos frases en castellano y una bandera («¿se puede reintentar?»). Las dos pantallas, Android y iOS, leerán exactamente el mismo texto, porque el mapeo vive en shared: si cada interfaz escribiera el suyo, una diría «sin conexión» y la otra «no hay internet», y nadie sabría por qué.

Es una lección solo de commonMain y commonTest: no hay una línea de Android ni de Swift, y no se instala nada nuevo. Por el camino aparecen dos trampas: un error que no se traduce nunca, y un doble de prueba que mentía sobre lo que lanza una red caída. Los reintentos automáticos del cliente HTTP son la lección siguiente, Reintentos con Ktor.

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.

Todos los comandos se escriben en la Terminal de Android Studio, en ~/Pokedex-KMP.

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/core/AppError.ktse crea: lo que pasó
shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/core/UiError.ktse crea: lo que se pinta
shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/core/AppErrorTest.ktse crea
shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/feature/list/PokemonPagingSourceTest.ktse modifica
Dos ficheros nuevos en el código común, uno de pruebas nuevo y uno de pruebas que se modifica.

Paso 1 · AppError: lo que le pasó a la petición

El archivo. AppError.kt es la lista cerrada de cosas que pueden salir mal, escritas como una persona las contaría: «no hay red», «tardó demasiado», «el servidor dijo que no», «no sé qué ha pasado». Es el diccionario entre las excepciones de Ktor y el resto de la app.

Créalo. En la vista Project, clic derecho sobre la carpeta shared → New → File y escribe la ruta desde la carpeta del módulo (el método de El módulo compartido y la prueba de humo):

src/commonMain/kotlin/com/sebastiannarvaez/pokedex/core/AppError.kt

El fichero aparece en shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/core/, ya con su carpeta. Si ese menú no te lo ofrece, la Terminal lo crea vacío: touch shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/core/AppError.kt. Ábrelo con doble clic.

El concepto. Es una sealed interface: una lista de casos que el compilador conoce entera. Un when sobre ella tiene que cubrir todos, y si añades un caso nuevo, la compilación se rompe en cada when que lo ignora. Es justo lo que quieres de un tipo de errores.

Escribe la línea package y, debajo, los cuatro casos. Los /** … */ son comentarios de documentación (KDoc): el IDE los muestra al pasar el ratón por encima del nombre.

AppError.kt: los cuatro casos
package com.sebastiannarvaez.pokedex.core
 
/**
 * Los cuatro errores que la app sabe contar.
 * Cuatro, no catorce: un error de la app no es un código HTTP, es una de las
 * pocas cosas distintas que se le pueden decir a una persona.
 */
sealed interface AppError {                       // lista cerrada de casos
    /** No hay red, o el servidor no contesta. */
    data object SinConexion : AppError            // sin datos: solo hay una instancia
 
    /** El servidor contestó, pero tardó demasiado. */
    data object Lento : AppError
 
    /** El servidor dijo que no: 4xx o 5xx. */
    data class Servidor(val codigo: Int) : AppError   // este sí lleva un dato: el código HTTP
 
    /** Cualquier otra cosa. Se guarda su mensaje para poder registrarla. */
    data class Inesperado(val causa: String) : AppError
}

Paso 2 · Traducir una excepción, y la que no se traduce

El archivo. El mismo AppError.kt. Ahora le añades dos funciones: una que convierte cualquier excepción en uno de los cuatro casos, y otra que dice si merece la pena ofrecer «Reintentar».

El concepto. Throwable.aAppError() es una función de extensión: se escribe como si Throwable la tuviera, y dentro this es la excepción. El when usa is para preguntar de qué tipo es y trata el valor como tal en esa rama.

Arriba del fichero, entre la línea package y la sealed interface, van los imports de lo que usan las dos funciones. Casi todos son de Ktor, que ya tienes desde HTTP y Ktor: el cliente de PokeAPI; no se instala nada:

AppError.kt: imports
import io.ktor.client.network.sockets.ConnectTimeoutException   // no se pudo conectar a tiempo
import io.ktor.client.plugins.HttpRequestTimeoutException       // la petición tardó más de la cuenta
import io.ktor.client.plugins.ResponseException                 // el servidor respondió con 4xx o 5xx
import io.ktor.http.HttpStatusCode                              // los códigos HTTP con nombre
import kotlinx.coroutines.CancellationException                 // «alguien canceló esta corrutina»
import kotlinx.io.IOException                                   // fallo de entrada/salida: la red se cayó

(kotlinx.io.IOException es la versión multiplataforma de la excepción de red; llega con Ktor.) Y al final del fichero, una función y una propiedad. La propiedad de extensión (esReintentable) es como la función de extensión pero se lee sin paréntesis, error.esReintentable, y en vez de cuerpo lleva un get() que calcula el valor cada vez que se pide:

AppError.kt: aAppError y esReintentable
fun Throwable.aAppError(): AppError = when (this) {        // `this` es la excepción
    // La cancelación NO se traduce: se vuelve a lanzar (explicado abajo). `throw this`
    // lanza otra vez la propia excepción; `throw` es una expresión, así que vale
    // como resultado de una rama del `when`.
    is CancellationException -> throw this
    // Los dos timeouts también son IOException, así que van ANTES: el `when`
    // se queda con la primera rama que encaja. La coma une dos condiciones:
    // la rama vale si es cualquiera de las dos.
    is HttpRequestTimeoutException, is ConnectTimeoutException -> AppError.Lento
    // El servidor contestó con 4xx o 5xx. (Ktor solo lanza esta excepción si se
    // lo pides con `expectSuccess`: se activa en la lección siguiente.)
    is ResponseException -> AppError.Servidor(response.status.value)
    // Cualquier otro fallo de entrada/salida: no hay red.
    is IOException -> AppError.SinConexion
    // Lo demás: se guarda el mensaje; si no hay, el nombre de la clase de la
    // excepción (`this::class` es su clase, `simpleName` su nombre sin paquete y
    // `orEmpty()` cambia un `null` por un texto vacío).
    else -> AppError.Inesperado(message ?: this::class.simpleName.orEmpty())
}
 
/** Si vale la pena ofrecer un botón de reintentar. */
val AppError.esReintentable: Boolean                       // propiedad de extensión
    get() = when (this) {
        // Sin `is`: `SinConexion` y `Lento` son objetos únicos y se comparan por igualdad.
        AppError.SinConexion, AppError.Lento -> true       // la red puede volver
        // Un 5xx es un servidor que quizá vuelva; un 4xx va a seguir siendo 4xx.
        // `InternalServerError` es el 500, así que «>= 500» son los 5xx.
        is AppError.Servidor -> codigo >= HttpStatusCode.InternalServerError.value
        is AppError.Inesperado -> false                    // no sabemos qué es: no prometemos nada
    }

La primera rama es la que más se olvida, y por eso lleva la analogía. Ya conoces la cancelación de Alcance, Job y cancelación: cuando el usuario sale de la pantalla, la corrutina recibe una CancellationException. Si aquí se mapeara a Inesperado, la corrutina seguiría viva después de que su pantalla desapareciera, y no habría error, ni traza, ni nada. Por eso se relanza.

¿Y la fuente de páginas, PokemonPagingSource? Hace lo mismo que esta función, a su manera: su load tiene un catch (e: CancellationException) aparte que la relanza con throw e, y solo después un catch (e: Throwable) que convierte el resto de los fallos en LoadResult.Error (lo viste en Paginación con Paging 3). Es la misma regla en dos sitios: un fallo se cuenta, la cancelación se deja pasar.

Paso 3 · UiError: el texto, compartido

El archivo. UiError.kt es lo que de verdad llega a las pantallas: un título, un detalle y si hay botón de reintentar. Es la «cara» de AppError, ya traducida a castellano.

Créalo. Igual que antes: clic derecho sobre shared → New → File → src/commonMain/kotlin/com/sebastiannarvaez/pokedex/core/UiError.kt (o touch en la Terminal).

El concepto. Aquí sí es una data class, no una jerarquía sellada: este tipo cruza a Swift, y una data class llega como una clase con tres campos que Swift lee tal cual (error.titulo), sin tener que decidir entre casos con un switch. Y sePuedeReintentar viaja dentro: una interfaz que tuviera que preguntarse «¿esto se reintenta?» acabaría contestándolo distinto en cada plataforma.

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/core/UiError.kt
package com.sebastiannarvaez.pokedex.core
 
/**
 * El error tal y como lo pinta una pantalla.
 * Lo comparten las dos apps: si cada una escribiera su texto, una diría
 * «sin conexión» y la otra «no hay internet».
 */
data class UiError(
    val titulo: String,                // lo que va en grande
    val detalle: String,               // la frase de debajo
    val sePuedeReintentar: Boolean,    // si se dibuja el botón «Reintentar»
)
 
fun AppError.aUiError(): UiError = when (this) {   // exhaustivo: un caso nuevo de AppError rompe aquí
    AppError.SinConexion -> UiError(
        titulo = "Sin conexión",
        detalle = "Revisa tu red e inténtalo otra vez.",
        sePuedeReintentar = true,
    )
    AppError.Lento -> UiError(
        titulo = "La respuesta tardó demasiado",
        detalle = "El servidor no contestó a tiempo.",
        sePuedeReintentar = true,
    )
    is AppError.Servidor -> UiError(
        titulo = "PokeAPI no responde",
        detalle = "El servidor devolvió un error $codigo.",   // `$codigo` mete el número en el texto
        sePuedeReintentar = esReintentable,                   // 503 sí, 404 no
    )
    is AppError.Inesperado -> UiError(
        titulo = "Algo se rompió",
        detalle = causa,
        sePuedeReintentar = false,
    )
}

Paso 4 · Las pruebas del mapeo

Un test para cada decisión del diseño. Créalo: clic derecho sobre shared → New → File → src/commonTest/kotlin/com/sebastiannarvaez/pokedex/core/AppErrorTest.kt (carpeta core incluida; o touch). Son cinco pruebas; las dos que sostienen el diseño son la cancelación y el reintento solo cuando tiene sentido:

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/core/AppErrorTest.kt
package com.sebastiannarvaez.pokedex.core
 
import kotlinx.coroutines.CancellationException
import kotlinx.io.IOException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
 
class AppErrorTest {
 
    @Test
    fun unFalloDeRedEsSinConexion() {
        // `IOException` es lo que lanza de verdad un socket caído (el «socket» es la
        // conexión abierta con el servidor; si la red falla, se corta y salta esto).
        assertEquals(AppError.SinConexion, IOException("socket cerrado").aAppError())
    }
 
    @Test
    fun laCancelacionNoSeTraduce() {
        // `assertFailsWith` pasa si el bloque lanza esa excepción: aquí
        // queremos que `aAppError` NO la convierta en un AppError.
        assertFailsWith<CancellationException> {
            CancellationException("la vista se fue").aAppError()
        }
    }
 
    @Test
    fun soloSeOfreceReintentarCuandoTieneSentido() {
        assertTrue(AppError.SinConexion.esReintentable)
        assertTrue(AppError.Lento.esReintentable)
        assertTrue(AppError.Servidor(503).esReintentable)
        assertFalse(AppError.Servidor(404).esReintentable)     // seguirá siendo 404
        assertFalse(AppError.Inesperado("vaya").esReintentable)
    }
 
    @Test
    fun lasDosAppsPintanElMismoTexto() {
        val error = AppError.SinConexion.aUiError()
 
        assertEquals("Sin conexión", error.titulo)
        assertTrue(error.sePuedeReintentar)
    }
 
    @Test
    fun unErrorDelServidorLlevaSuCodigoAlTexto() {
        val error = AppError.Servidor(503).aUiError()
 
        assertTrue(error.detalle.contains("503"), "detalle inesperado: ${error.detalle}")
        assertTrue(error.sePuedeReintentar)
    }
}

Como el test está en el mismo paquete que AppError, no necesita importarlo. Ejecútalo en la Terminal de Android Studio:

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

Debe terminar en BUILD SUCCESSFUL. Un fallo típico: Unresolved reference 'IOException' (o cualquier otra palabra en rojo) significa que falta un import. Sitúa el cursor sobre la palabra y pulsa Option+Enter: el IDE ofrece añadirlo.

Paso 5 · El test que pilla a un doble mintiendo

Falta una comprobación, y es la que más enseña. Cuando escribiste Probar la paginación, el doble de PokeApi que está al principio de PokemonPagingSourceTest.kt simulaba la caída de red así:

PokemonPagingSourceTest.kt: el doble, tal como está ahora
        if (falla) error("sin red")

error(...) lanza un IllegalStateException, no lo que lanza un socket caído. Para verlo, añade al final del test unFalloDeRedLlegaComoErrorYNoComoExcepcion dos líneas que pregunten lo que a la pantalla le importa: si ese fallo se puede reintentar. (Necesitan dos imports: com.sebastiannarvaez.pokedex.core.aAppError y com.sebastiannarvaez.pokedex.core.esReintentable.)

PokemonPagingSourceTest.kt: dos líneas nuevas al final del test
        assertTrue(resultado is PagingSource.LoadResult.Error)   // esta línea ya existe
        // Y ese fallo de red tiene que poder reintentarse. `assertTrue(… is …)` ya
        // le dijo a Kotlin que `resultado` es un `Error`, así que se puede leer su
        // `throwable` sin convertirlo.
        val error = resultado.throwable.aAppError()
        assertTrue(error.esReintentable, "no se puede reintentar: $error")

Ejecuta ./gradlew :shared:jvmTest. Falla. La Terminal solo dice java.lang.AssertionError at PokemonPagingSourceTest.kt:109 (el número de línea puede variar unas pocas); el mensaje entero está en el informe (open shared/build/reports/tests/jvmTest/index.html, y pulsa la clase PokemonPagingSourceTest):

java.lang.AssertionError: no se puede reintentar: Inesperado(causa=sin red)

Ejecuta los tests de las tres plataformas otra vez, como en el paso 4.

Punto de control

  • ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test termina en BUILD SUCCESSFUL con 30 / 29 / 29 tests (JVM / Android host / iOS) con tus ejercicios, o 24 / 23 / 23 sin ellos: a los de antes se suman los 5 de AppErrorTest 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.
  • AppErrorTest.laCancelacionNoSeTraduce pasa: aAppError() relanza la CancellationException.
  • El doble de PokemonPagingSourceTest lanza IOException, y el test que pregunta por esReintentable pasa.
  • AppError.Servidor existe, pero todavía no se produce: Ktor no lanza nada con un 503 hasta que se lo pides. Es lo que arregla la lección siguiente.

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.¿Por qué AppError es una sealed interface y UiError una data class, si los dos describen errores?

  2. 2.aAppError() empieza con is CancellationException -> throw this. ¿Qué pasaría si en vez de relanzarla se mapeara a Inesperado?

  3. 3.Un doble de prueba simulaba la caída de red con error("sin red") y un test afirmaba que ese error era reintentable. El test falló. ¿Quién tenía razón?

  4. 4.En aAppError(), la rama de los dos timeouts va **antes** que la de IOException. ¿Por qué importa el orden?

Faltan 4 de 4