POST y errores de red
Al terminar: Enviar datos con un POST y convertir los fallos de red en errores que la pantalla entienda.
Al terminar esta lección Marcador sabrá enviar datos a un servidor (POST) y, sobre todo, contar los fallos de la red en su propio idioma: sin conexión, o el servidor dijo que no. Verás ese cambio en la pantalla de árbitros, que ya no mirará excepciones de Java, y dejarás preparado el repositorio que publica crónicas. La pantalla para publicarlas es la lección siguiente.
Importa porque leer datos (GET) es solo la mitad de una app: publicar, registrarse o guardar algo en la nube es enviar (POST). Y enviar es donde más cosas fallan, así que aquí aprendes a distinguir los dos tipos de fallo de la red. Temas: POST y @Body, el código 201, HttpException frente a IOException, un error de dominio y recoverCatching.
Un aviso antes de empezar: JSONPlaceholder es un servicio de pruebas y no guarda nada. Responde «creado» y un número, pero lo que envías se pierde. El número es siempre 101, aunque envíes dos veces. No es un fallo de tu app.
POST: enviar datos al servidor
Hasta ahora el método era GET: «dame». Con POST la app envía algo: «toma, guarda esto». La petición lleva un cuerpo (casi siempre JSON) con los datos, y el servidor contesta con otro cuerpo.
El sello de un POST que sale bien suele ser 201, «creado», en vez del 200 de un GET. Y la respuesta trae el recurso nuevo con su número, el id.
En Retrofit, un POST es una función más de la interfaz: @POST con la ruta y @Body en el parámetro que irá como cuerpo. Retrofit convierte el DTO en JSON con el conversor que montaste en Retrofit y la primera llamada.
@Serializable data class ReservaDto(val socio: String, val libro: String) // lo que se ENVÍA
@Serializable data class ReservaCreadaDto(val id: Int) // lo que RESPONDE
interface BibliotecaApi {
// POST https://biblioteca.example/reservas, con la reserva como cuerpo JSON
// @Body: este parámetro se convierte en JSON y viaja dentro de la petición
@POST("reservas") suspend fun reservar(@Body reserva: ReservaDto): ReservaCreadaDto
}Dos clases de fallo
La red falla de dos maneras, y Retrofit las distingue con dos excepciones:
| Qué pasó | Excepción | Ejemplo |
|---|---|---|
| Hubo respuesta, pero con código de error | HttpException, que guarda el código | 404, 500, 503 |
| No hubo respuesta | IOException | sin red, servidor que no existe, tiempo agotado |
Esa distinción importa para el mensaje: «No hay conexión» y «El servidor falló» son problemas distintos, y el usuario hace cosas distintas con cada uno. Pero la pantalla no debe conocer HttpException ni IOException: son de Retrofit y de Java. Si mañana cambias de librería, tendrías que tocar la pantalla.
La solución es un error de dominio, un tipo propio de la app que dice qué pasó en su idioma. Y una función que traduce las excepciones a él, en un solo sitio (el repositorio). Es la misma idea del DTO frente al modelo, ahora con los errores.
Un ejemplo con datos ajenos (reservas en una biblioteca), la traducción y su salida. Lee primero los comentarios: cada línea nueva dice qué hace.
import kotlinx.coroutines.*
import java.io.IOException
import kotlin.coroutines.cancellation.CancellationException
data class ReservaDto(val socio: String, val libro: String)
data class ReservaCreadaDto(val id: Int)
// Un error de HTTP: el servidor SÍ contestó, pero con un código de error
class ErrorHttp(val codigo: Int) : Exception("HTTP $codigo")
interface BibliotecaApi { suspend fun reservar(reserva: ReservaDto): ReservaCreadaDto }
// Una API de mentira: responde con el siguiente número, o falla con lo que le pongas
class BibliotecaFalsa(var fallo: Exception? = null) : BibliotecaApi {
private var siguiente = 1
override suspend fun reservar(reserva: ReservaDto): ReservaCreadaDto {
fallo?.let { throw it }
println("el servidor recibe: $reserva")
return ReservaCreadaDto(siguiente++)
}
}
// Lo que le puede pasar a una reserva, en el idioma de la app
sealed interface ErrorDeReserva {
data object SinConexion : ErrorDeReserva
data class Servidor(val codigo: Int) : ErrorDeReserva
}
// Result.failure necesita una excepción: esta lleva dentro el ErrorDeReserva
class FalloDeReserva(val error: ErrorDeReserva) : Exception(error.toString())
// Traduce la excepción de la red al error de la app, en un solo sitio
fun Throwable.aErrorDeReserva(): Throwable = when (this) {
is ErrorHttp -> FalloDeReserva(ErrorDeReserva.Servidor(codigo)) // hubo respuesta, con código de error
is IOException -> FalloDeReserva(ErrorDeReserva.SinConexion) // no hubo respuesta
else -> this // un fallo de programación no se disfraza
}
// Como `intentar` de Marcador: atrapa el fallo, pero relanza la cancelación.
// <T> es «un tipo cualquiera»: devuelve un Result de lo que devuelva el bloque.
// `suspend () -> T` es «una función suspend sin parámetros que devuelve T».
suspend fun <T> intentar(bloque: suspend () -> T): Result<T> =
try { Result.success(bloque()) } catch (e: CancellationException) { throw e } catch (e: Exception) { Result.failure(e) }
class ReservasRepositorio(private val api: BibliotecaApi) {
// Result<Int>: el número de reserva, o el fallo ya traducido
suspend fun reservar(socio: String, libro: String): Result<Int> =
intentar { api.reservar(ReservaDto(socio, libro)).id }
.recoverCatching { throw it.aErrorDeReserva() } // cambia la excepción de red por la de la app
}
fun main() = runBlocking {
val api = BibliotecaFalsa()
val repositorio = ReservasRepositorio(api)
println(repositorio.reservar("Lucía", "Dune"))
println(repositorio.reservar("Lucía", "Emma"))
api.fallo = IOException("sin red")
// exceptionOrNull() saca la excepción del Result; `as FalloDeReserva` dice «tómala por
// un FalloDeReserva» para poder leer su `.error`
println((repositorio.reservar("Marcos", "Dune").exceptionOrNull() as FalloDeReserva).error)
api.fallo = ErrorHttp(500)
println((repositorio.reservar("Marcos", "Dune").exceptionOrNull() as FalloDeReserva).error)
}el servidor recibe: ReservaDto(socio=Lucía, libro=Dune)
Success(1)
el servidor recibe: ReservaDto(socio=Lucía, libro=Emma)
Success(2)
SinConexion
Servidor(codigo=500)recoverCatching es la pieza nueva. Si el Result es un éxito, lo deja pasar tal cual. Si es un fallo, ejecuta su bloque con la excepción en it. Y si el bloque lanza otra excepción, esa pasa a ser el nuevo fallo del Result: por eso aquí se escribe throw it.aErrorDeReserva(), para cambiar la excepción de red por la de la app sin que el Result deje de ser un Result. Con un ejemplo: Result.failure<Int>(IOException()).recoverCatching { throw IllegalStateException("sin red") } sigue siendo un fallo, pero ahora de tipo IllegalStateException. Este servidor de mentira numera 1, 2…; JSONPlaceholder responde siempre 101.
Falta una pieza para el ejercicio: el when sin sujeto. El de arriba compara un valor (when (this)); este no compara nada, cada rama es una condición completa y gana la primera que se cumple. Con &&, la segunda parte solo se mira si la primera es cierta, y por eso Kotlin ya sabe que, dentro de la condición, la excepción es del tipo que acabas de comprobar:
import java.io.IOException
class ErrorHttp(val codigo: Int) : Exception("HTTP $codigo")
// `when` SIN sujeto: cada rama es una condición completa (da true o false) y gana la
// primera que da true. Sirve cuando las condiciones no son «el mismo valor, comparado».
fun aviso(e: Throwable): String? = when {
e is IOException -> "Sin conexión"
// `&&` solo mira el código si antes se cumple `e is ErrorHttp`; por eso, a partir de
// ahí, Kotlin ya sabe que `e` es un ErrorHttp y te deja leer `e.codigo`.
e is ErrorHttp && e.codigo == 401 -> "Vuelve a iniciar sesión"
e is ErrorHttp -> "El servidor falló (${e.codigo})"
else -> null // cualquier otra cosa: no es un error de red, no hay aviso
}
fun main() {
println(aviso(IOException("sin red")))
println(aviso(ErrorHttp(401)))
println(aviso(ErrorHttp(500)))
println(aviso(IllegalStateException("un fallo mío")))
}Sin conexión
Vuelve a iniciar sesión
El servidor falló (500)
nullReto · Ejercicio: traducir los errores de una tienda
Fuera de Marcador, en el Kotlin Playground.
Una tienda en línea puede fallar de tres maneras. Define un error de dominio sellado con tres casos: sin conexión, pedido que no existe y fallo del servidor (este con el código). Define una clase de excepción ErrorHttp que guarda un código.
Escribe una función de extensión aErrorDeTienda() sobre Throwable que devuelva el error de dominio, o null si la excepción no es de red:
- una
IOExceptiones «sin conexión»; - un
ErrorHttpcon el código404es «pedido que no existe»; - cualquier otro
ErrorHttpes «fallo del servidor» con su código; - cualquier otra excepción devuelve
null.
Escribe otra función que convierta cada error de dominio en el texto para el usuario. En main, recorre una IOException, un ErrorHttp(404), un ErrorHttp(503) y un IllegalStateException("fallo mío"). Para los tres primeros imprime el texto del error; para el cuarto, que no es un error de red, imprime «Fallo de la app, no de la red». La consola debe mostrar:
Revisa tu conexión
Ese pedido no existe
La tienda falló (503); prueba más tarde
Fallo de la app, no de la redVer solución
import java.io.IOException
class ErrorHttp(val codigo: Int) : Exception("HTTP $codigo")
sealed interface ErrorDeTienda {
data object SinConexion : ErrorDeTienda
data object PedidoNoExiste : ErrorDeTienda
data class Servidor(val codigo: Int) : ErrorDeTienda
}
// De la excepción de la red al error de la app; null si no es un error de red
fun Throwable.aErrorDeTienda(): ErrorDeTienda? = when {
this is IOException -> ErrorDeTienda.SinConexion
this is ErrorHttp && codigo == 404 -> ErrorDeTienda.PedidoNoExiste
this is ErrorHttp -> ErrorDeTienda.Servidor(codigo)
else -> null
}
// Del error de la app al texto del usuario: when exhaustivo, sin else
fun texto(error: ErrorDeTienda): String = when (error) {
ErrorDeTienda.SinConexion -> "Revisa tu conexión"
ErrorDeTienda.PedidoNoExiste -> "Ese pedido no existe"
is ErrorDeTienda.Servidor -> "La tienda falló (${error.codigo}); prueba más tarde"
}
fun main() {
listOf(IOException("sin red"), ErrorHttp(404), ErrorHttp(503), IllegalStateException("fallo mío")).forEach {
val error = it.aErrorDeTienda()
println(if (error != null) texto(error) else "Fallo de la app, no de la red")
}
}El orden de las condiciones importa: el caso del 404 va antes que el de «cualquier otro ErrorHttp», o nunca se alcanzaría. El when sobre ErrorDeTienda no necesita else: el compilador sabe que están todos los casos. Y el null es lo que separa «error de red» de «fallo de programación»: un bug no se disfraza de «sin conexión».
Paso · El repositorio que publica y los errores traducidos
Objetivo. Añadir el POST a LigaApi, el error de dominio, el repositorio de crónicas y el traductor de errores, y hacer que la pantalla de árbitros use el idioma de la app. Al terminar, Árbitros sigue funcionando igual (también sin red), el proyecto compila y los 9 tests pasan. La crónica aún no tiene pantalla: la tendrá en la siguiente lección.
Crea los ficheros. Con el selector en Android, despliega app → kotlin+java. Nace la feature cronica, con su domain y su data (la ui llega en la lección siguiente): clic derecho sobre com.sebastiannarvaez.marcador → New → Package → añade .feature.cronica.domain al final del nombre que aparece ya escrito, y repite con .feature.cronica.data. Después, clic derecho sobre domain, dentro de cronica → New → Kotlin Class/File → File → Cronicas, y sobre el data de cronica → CronicasRepositoryRed. Los otros tres ficheros van en carpetas de core que ya tienes (si no las ves, despliega core): ErrorDeRed en domain, ErroresDeRed en network y MensajeDeError en ui, los tres dentro de core.
ErrorDeRed va en core porque no es de ninguna feature (lo entienden las dos que usan la red, árbitros y crónica, y una pieza de pantalla) y no sabe de Retrofit: es Kotlin puro. ErroresDeRed sí sabe de Retrofit, así que se queda con la API en core/network; MensajeDeError es la pieza de pantalla compartida y va en core/ui.
Piezas nuevas de este paso:
@POST("posts")y@Body: unPOSTcon el DTO como cuerpo JSON, como en el ejemplo.HttpException: la excepción que Retrofit lanza cuando el servidor contesta con un código de error. Su funcióncode()da ese código (404, 500…).Result.recoverCatching { … }: si hubo fallo, ejecuta su bloque, y lo que ese bloque lance pasa a ser el nuevo fallo.fun Throwable.aErrorDeRed(): una función de extensión (Extensiones) para escribirit.aErrorDeRed()sobre cualquier excepción.
Intenta escribirlo tú, en este orden. Cada punto usa solo lo que ya has visto; al ir haciéndolos, Android Studio marcará en rojo lo que aún no existe (por ejemplo aErrorDeRed hasta el punto 3). Es normal: el proyecto vuelve a compilar al terminar el punto 4, y los tests vuelven a pasar al terminar el punto 5.
- En
LigaApi.kt: dos DTO nuevos,CronicaDto(lo que se envía:title,bodyyuserId) yCronicaPublicadaDto(lo que responde: solo elid), y una funciónpublicarCronicacon@POST("posts")y@Body. - En
core/domain/ErrorDeRed.kt: el error sellado conSinConexionyServidor(codigo), y la excepciónFalloDeRedque lo lleva dentro. Enfeature/cronica/domain/Cronicas.kt:CronicaPublicada(número, título y texto) y la interfazCronicasRepositoryconpublicar(titulo, texto), que devuelve el número en unResult. - En
core/network/ErroresDeRed.kt: la funciónaErrorDeRed(HttpExceptionesServidorcon sucode();IOExceptionesSinConexion; el resto, igual). Enfeature/cronica/data:CronicasRepositoryRed.ArbitrosRepositoryRedtambién traduce sus fallos. - En
core/ui:mensajeParaElUsuario()(el texto de cada error).ArbitrosViewModeldeja de mirarIOExceptiony usamensajeParaElUsuario(). - El test de árbitros: su fallo ya no es una
IOExceptionsuelta, sino el error de dominio.
Debes ver Árbitros funcionando como antes y 9 tests que pasan.
Dónde va cada trozo (obligatorio). Los ficheros nuevos van enteros. En los que ya existen:
LigaApi.kt: dosimport, los dos DTO nuevos y la funciónpublicarCronicadentro de la interfaz.ArbitrosRepositoryRed.kt: el cuerpo dearbitros()y unimportnuevo (aErrorDeRed, que vive encore.network).ArbitrosViewModel.kt: elonFailure, unimportmenos (java.io.IOException) y uno nuevo (mensajeParaElUsuario, decore.ui).ArbitrosViewModelTest.kt: la línea del repositorio falso del segundo test y losimport.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
En LigaApi.kt van dos import nuevos junto a los demás:
import retrofit2.http.Body
import retrofit2.http.POSTLos dos DTO, justo debajo de DireccionDto:
// Lo que se ENVIA en el POST: un «post» de JSONPlaceholder (titulo, cuerpo y autor).
@Serializable
data class CronicaDto(val title: String, val body: String, val userId: Int)
// Lo que RESPONDE: lo enviado mas el `id` nuevo. Solo interesa el id.
@Serializable
data class CronicaPublicadaDto(val id: Int)Y la función nueva, dentro de la interfaz LigaApi, debajo de arbitros():
// POST https://jsonplaceholder.typicode.com/posts con el DTO como cuerpo JSON.
// Responde 201 («creado») y el id 101. Es un servicio de pruebas: no guarda nada, asi
// que el id es SIEMPRE 101 aunque publiques dos veces.
@POST("posts")
suspend fun publicarCronica(@Body cronica: CronicaDto): CronicaPublicadaDtopackage com.sebastiannarvaez.marcador.core.domain
// Lo que le puede pasar a una peticion, dicho en el idioma de la app. La pantalla no
// conoce `HttpException` ni `IOException` (son de Retrofit y de Java): conoce esto.
sealed interface ErrorDeRed {
// No hubo respuesta: sin red, el servidor no existe o tardo demasiado.
data object SinConexion : ErrorDeRed
// Hubo respuesta, pero con un codigo de error (404, 500...).
data class Servidor(val codigo: Int) : ErrorDeRed
}
// `Result.failure` necesita un Throwable: esta excepcion lleva dentro el ErrorDeRed.
class FalloDeRed(val error: ErrorDeRed) : Exception(error.toString())package com.sebastiannarvaez.marcador.feature.cronica.domain
// La cronica tal como quedo publicada: el numero que dio el servidor y lo enviado.
// Aun no se usa: la pantalla de la leccion siguiente la mostrara.
data class CronicaPublicada(val numero: Int, val titulo: String, val texto: String)
// Publicar es UNA accion que termina: `suspend`. El fallo esperable (sin red, error del
// servidor) sale en el Result como FalloDeRed, no como excepcion.
interface CronicasRepository {
suspend fun publicar(titulo: String, texto: String): Result<Int>
}package com.sebastiannarvaez.marcador.core.network
import com.sebastiannarvaez.marcador.core.domain.ErrorDeRed
import com.sebastiannarvaez.marcador.core.domain.FalloDeRed
import retrofit2.HttpException
import java.io.IOException
// De las excepciones de la red al error de dominio, en UN sitio:
// HttpException -> hubo respuesta con codigo de error (4xx, 5xx): Servidor(codigo)
// IOException -> no hubo respuesta (sin red, host desconocido, timeout): SinConexion
// lo demas -> tal cual (un fallo de programacion no se disfraza de «sin red»).
fun Throwable.aErrorDeRed(): Throwable = when (this) {
is HttpException -> FalloDeRed(ErrorDeRed.Servidor(code()))
is IOException -> FalloDeRed(ErrorDeRed.SinConexion)
else -> this
}package com.sebastiannarvaez.marcador.feature.cronica.data
import com.sebastiannarvaez.marcador.core.network.CronicaDto
import com.sebastiannarvaez.marcador.core.network.LigaApi
import com.sebastiannarvaez.marcador.core.network.aErrorDeRed
import com.sebastiannarvaez.marcador.feature.cronica.domain.CronicasRepository
import com.sebastiannarvaez.marcador.torneo.intentar
import javax.inject.Inject
// Publica con un POST. `intentar` atrapa el fallo (y relanza la cancelacion);
// `recoverCatching` cambia la excepcion de Retrofit por la de dominio.
class CronicasRepositoryRed @Inject constructor(private val api: LigaApi) : CronicasRepository {
override suspend fun publicar(titulo: String, texto: String): Result<Int> = intentar {
api.publicarCronica(CronicaDto(title = titulo, body = texto, userId = 1)).id
}.recoverCatching { throw it.aErrorDeRed() }
}CronicasRepositoryRed nace con @Inject constructor, como ArbitrosRepositoryRed en Red en Marcador: la lista de árbitros: así Hilt sabrá construirla cuando alguien se la pida. Todavía nadie se la pide. En la lección siguiente le dirás, con un @Binds, para qué interfaz sirve. Y los tests la siguen construyendo a mano con una API falsa: el @Inject es una etiqueta y no cambia el constructor.
En ArbitrosRepositoryRed.kt, un import nuevo junto a los demás (la función aErrorDeRed vive en core.network, otra carpeta):
import com.sebastiannarvaez.marcador.core.network.aErrorDeRedY la función arbitros() pasa a traducir sus fallos (el resto del fichero no cambia):
// Los fallos tambien se traducen: hacia fuera no sale ninguna excepcion de Retrofit.
override suspend fun arbitros(): List<Arbitro> = try {
api.arbitros().map { Arbitro(id = it.id, nombre = it.name, ciudad = it.address.city) }
} catch (error: Exception) {
throw error.aErrorDeRed()
}package com.sebastiannarvaez.marcador.core.ui
import com.sebastiannarvaez.marcador.core.domain.ErrorDeRed
import com.sebastiannarvaez.marcador.core.domain.FalloDeRed
// El texto que ve el usuario para cada fallo. Vive en la UI: el dominio dice QUE paso
// (ErrorDeRed) y la pantalla decide COMO contarlo. `when` exhaustivo sobre el sealed.
fun Throwable.mensajeParaElUsuario(): String = when (this) {
is FalloDeRed -> when (val error = error) {
ErrorDeRed.SinConexion -> "No hay conexión"
is ErrorDeRed.Servidor -> "El servidor falló (código ${error.codigo}); prueba más tarde"
}
else -> message ?: "Algo salió mal"
}En ArbitrosViewModel.kt se borra el import java.io.IOException y se añade este otro (mensajeParaElUsuario vive en core.ui, otra carpeta):
import com.sebastiannarvaez.marcador.core.ui.mensajeParaElUsuarioDespués, el onFailure se simplifica:
_uiState.value = intentar { repositorio.arbitros() }.fold(
onSuccess = { ArbitrosUiState.Exito(it) },
// El repositorio ya tradujo el fallo a FalloDeRed; aqui solo se pone en palabras.
onFailure = { ArbitrosUiState.Error(it.mensajeParaElUsuario()) },
)Por último, ArbitrosViewModelTest.kt cambia en dos sitios: el fallo del segundo test ya no es una IOException suelta sino el error de dominio, y los import cambian (se borra java.io.IOException y se añaden dos):
import com.sebastiannarvaez.marcador.core.domain.ErrorDeRed
import com.sebastiannarvaez.marcador.core.domain.FalloDeRed val repositorio = FakeArbitrosRepository(fallo = FalloDeRed(ErrorDeRed.SinConexion))Tres decisiones que quizá te hayas preguntado.
when (val error = error)enMensajeDeError.kt: toma elerrorque lleva elFalloDeRedy lo guarda en una variable local que también se llamaerror, para usarlo en las ramas de abajo (error.codigo). No es un nombre mágico: podrías llamarlae.- Dos estilos,
try/catchyrecoverCatching.arbitros()devuelve una lista y, si falla, lanza: así lo prometía su interfaz desde la lección anterior, y por eso llevatry/catch.publicardevuelve unResult, y por eso llevaintentaryrecoverCatching. Dos formas de decir lo mismo; en Dos fuentes: el almacén y el repositorio los árbitros pasarán también aResult. catch (error: Exception)y la cancelación. Cancelar una corrutina lanza una excepción especial, ycatch (Exception)la atrapa. No se pierde:aErrorDeRed()solo cambia las excepciones de red y devuelve las demás tal cual (else -> this), y elthrowla vuelve a lanzar, así que la cancelación sigue su camino.
Qué cambió en los árbitros. El repositorio ya traduce los fallos, así que el ViewModel no necesita saber de IOException: recibe un FalloDeRed y lo pone en palabras con mensajeParaElUsuario(). Por eso el test de árbitros ahora lanza FalloDeRed(SinConexion), que es lo que el repositorio de verdad entrega. Un detalle: lo que no sea un error de red muestra su propio mensaje en vez del genérico «No se pudieron cargar los árbitros».
Probarlo. Compila con ./gradlew :app:assembleGratisDebug y ejecuta la app. Entra en Árbitros: la lista de diez sale como antes. Ahora activa el modo avión del emulador (arrastra dos veces desde arriba para abrir los ajustes rápidos y pulsa el icono del avión), vuelve a Árbitros y pulsa Reintentar: debe verse «No hay conexión». Esta vez ese texto no sale de mirar IOException en el ViewModel, sino de aErrorDeRed() en el repositorio y de mensajeParaElUsuario() en la UI. Quita el modo avión. El POST no tiene pantalla todavía; en la siguiente lección lo verás funcionar.
Punto de control
LigaApitiene dos funciones,arbitros()(unGET) ypublicarCronica(unPOST), y el proyecto compila.- Árbitros sigue funcionando: la lista, y «No hay conexión» con el modo avión.
./gradlew :app:testGratisDebugUnitTestpasa con 9 tests sin tus ejercicios, o 12 si hiciste el ejercicio del temporizador de Tests de un ViewModel: fakes, setMain y Turbine: los ejercicios suman tests propios, y por eso tu cifra puede ser mayor que la de la guía.
Marcador sabe ya hablar en su idioma cuando la red falla. En La pantalla de la crónica y sus pruebas estrenas el repositorio que acabas de dejar escrito: una pantalla con un botón Publicar y los primeros tests de una capa de red.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Qué diferencia hay entre un
GETy unPOSTen la lección?2.Publicas dos veces la crónica y las dos veces ves «n.º 101». ¿Qué ocurre?
3.¿Qué distingue una
HttpExceptionde unaIOException?4.¿Por qué se traduce la excepción de la red a un
ErrorDeRedpropio, en un solo sitio (el repositorio)?5.En
intentar { api.reservar(…).id }.recoverCatching { throw it.aErrorDeReserva() }, la API falla con unaIOException. ¿Qué devuelve esa expresión?