wandres.dev
REDES · Retrofit, Ktor y OkHttp

Errores de red: del fallo real al estado de la UI

Sin conexión, tiempo agotado, 500, 429, certificado inválido, contrato roto y cancelación son sucesos distintos que la mayoría de las aplicaciones colapsan en un mismo mensaje inútil. Esta lección construye una taxonomía honesta del fallo de red, muestra dónde y cómo se traduce a un tipo cerrado del dominio, desmonta el modelo de estado de pantalla basado en tres casos excluyentes porque no sabe representar datos antiguos con recarga fallida, y define qué acción de recuperación merece cada clase de error.

⏱ 24 min

Hay una frase que aparece en casi todas las aplicaciones del mundo y que resume un fracaso de diseño completo: ha ocurrido un error, inténtalo de nuevo. Es una frase que no informa, no orienta y no permite decidir, y su presencia casi siempre indica que en algún punto del código alguien capturó una jerarquía de excepciones entera y la sustituyó por una constante. Lo interesante es que ese punto rara vez es fruto de la pereza; es fruto de una creencia razonable pero equivocada, la de que el fallo de red es un suceso excepcional del que poco se puede decir. En un servidor conectado por fibra a un metro del conmutador, esa creencia es casi cierta. En un teléfono que entra en un ascensor, cambia de célula, pierde la red mientras el usuario sube al metro, se reconecta a una red pública que exige aceptar unas condiciones y vuelve a perderla, el fallo no es excepcional: es el régimen normal de funcionamiento. Y lo que es normal no se modela con una excepción capturada al vuelo, se modela con tipos, con estado y con decisiones de producto tomadas de antemano.

🎯 Al terminar esta lección sabrás
  • Construir una taxonomía del fallo de red que distinga transporte, protocolo, contrato y cancelación.
  • Traducir esa taxonomía a un tipo cerrado del dominio en la frontera correcta, sin propagar excepciones de la biblioteca hacia arriba.
  • Diseñar un estado de pantalla capaz de representar datos antiguos con recarga fallida, que los tres casos excluyentes no saben expresar.
  • Asignar a cada clase de error la acción de recuperación que le corresponde, distinguiendo lo reintentable de lo que no lo es.

Una taxonomía honesta de lo que puede salir mal

El primer paso es dejar de hablar de errores de red en singular. Lo que se agrupa bajo ese nombre son al menos cinco familias con causas, duraciones y remedios completamente distintos, y la única razón por la que se confunden es que todas terminan en un bloque de captura.

La primera familia es el transporte: no hay conectividad, el nombre no resuelve, el destino es inalcanzable, el socket se corta a mitad. Nadie al otro lado escuchó nada, la operación no ocurrió y reintentar es seguro. La segunda son los tiempos agotados, que parecen transporte pero no lo son: el servidor pudo recibir la petición y procesarla, y solo se agotó la paciencia del cliente. Reintentar una escritura aquí puede duplicarla, y por eso los tiempos agotados son la razón concreta por la que existen las claves de idempotencia.

La tercera familia es el protocolo: el servidor respondió, y su respuesta dice algo. Un 401 pide credenciales, un 403 dice que las credenciales son válidas pero no bastan, un 404 dice que eso no existe, un 409 señala un conflicto de estado, un 422 detalla campos inválidos, un 429 pide que bajes el ritmo y suele acompañar el margen de espera, y la serie de los quinientos indica que el fallo es suyo y que quizá se resuelva solo. La cuarta es el contrato: la respuesta llegó, fue correcta a nivel de protocolo y no se pudo interpretar. Ese fallo no lo arregla el usuario reintentando, y silenciarlo es la mejor forma de no enterarse nunca de que el servidor cambió.

⚠️
La cancelación no es un error, y capturarla rompe las corrutinas

La quinta familia no es un fallo: es una cancelación. Cuando el usuario abandona la pantalla, el ámbito se cancela y la llamada en vuelo lanza una excepción de cancelación que debe propagarse para que la maquinaria de corrutinas funcione. Un bloque que captura la jerarquía general de excepciones se la traga, convierte la salida ordenada en un mensaje de error visible y deja corrutinas que se creen vivas. La regla es capturar tipos concretos, o bien relanzar explícitamente la cancelación antes de tratar cualquier otra cosa.

Traducir en la frontera, no en la pantalla

La traducción de esas familias a un vocabulario propio ocurre en el repositorio y su resultado es un tipo cerrado. La virtud de que sea cerrado no es estilística: obliga al compilador a exigir que cada consumidor contemple todos los casos, de modo que añadir mañana una variante nueva produce errores de compilación en los sitios exactos donde hay que decidir qué hacer.

sealed interface ErrorRed {
    data object SinConexion : ErrorRed
    data object TiempoAgotado : ErrorRed
    data object SesionCaducida : ErrorRed
    data class SinPermiso(val motivo: String?) : ErrorRed
    data object NoEncontrado : ErrorRed
    data class Validacion(val campos: Map<String, String>) : ErrorRed
    data class DemasiadasPeticiones(val esperaSegundos: Long?) : ErrorRed
    data class Servidor(val codigo: Int) : ErrorRed
    data class ContratoRoto(val detalle: String) : ErrorRed
}

suspend fun <T> ejecutar(bloque: suspend () -> T): Resultado<T> = try {
    Resultado.Exito(bloque())
} catch (e: CancellationException) {
    throw e                                   // jamas se captura
} catch (e: UnknownHostException) {
    Resultado.Error(ErrorRed.SinConexion)
} catch (e: SocketTimeoutException) {
    Resultado.Error(ErrorRed.TiempoAgotado)
} catch (e: IOException) {
    Resultado.Error(ErrorRed.SinConexion)
} catch (e: SerializationException) {
    Resultado.Error(ErrorRed.ContratoRoto(e.message.orEmpty()))
} catch (e: HttpException) {
    Resultado.Error(traducirCodigo(e))
}

Conviene notar dos decisiones dentro de ese fragmento. La primera es el orden: los tipos concretos van antes que los generales, porque el primero que encaja gana, y colocar la captura amplia de entrada y salida arriba anularía las dos anteriores. La segunda es que la ausencia de conexión no se deduce consultando al sistema si hay red antes de llamar. Preguntar antes es una carrera perdida —la red puede caerse entre la respuesta y la petición— y además una red presente no implica una red útil: los portales cautivos de hoteles y aeropuertos anuncian conectividad perfecta y devuelven una página de inicio de sesión a cualquier cosa que pidas. La única prueba de que hay red es una petición que funcionó.

🔁

Reintentable sin pensar

Transporte caído y errores de servidor en operaciones de lectura. Repetir es seguro y suele bastar con esperar unos segundos.

🧾

Reintentable con clave

Tiempos agotados en escrituras. Solo es seguro repetir si el servidor reconoce una clave de idempotencia que impida duplicar el efecto.

🔐

Requiere acción del usuario

Sesión caducada, permisos insuficientes y validación fallida. Reintentar sin cambiar nada producirá exactamente el mismo resultado.

🐞

Requiere acción del equipo

Contrato roto y respuestas incoherentes. No es un problema del usuario, es un aviso que debe llegar a la telemetría y no a un mensaje genérico.

El estado de pantalla no son tres casos excluyentes

El modelo que casi todo el mundo escribe primero declara tres estados mutuamente excluyentes: cargando, contenido y error. Es limpio, es fácil de dibujar y es incapaz de representar la situación más común de una aplicación móvil real, que es tener datos válidos de hace un minuto y que la recarga acabe de fallar. Con tres casos excluyentes solo caben dos respuestas y ambas son malas: sustituir una lista perfectamente útil por una pantalla de error, u ocultar el fallo y mentir sobre la frescura de lo que se muestra.

La corrección consiste en separar dos ejes que ese modelo confunde: qué contenido hay y qué está pasando con él. El contenido puede estar ausente, presente o presente y antiguo; la actividad puede ser ninguna, carga inicial o refresco; y el fallo puede afectar a la pantalla entera o solo a la última operación.

data class EstadoPantalla(
    val contenido: List<Producto>? = null,
    val cargando: Boolean = false,
    val refrescando: Boolean = false,
    val errorBloqueante: ErrorRed? = null,   // no hay nada que mostrar
    val avisoTransitorio: ErrorRed? = null,  // hay contenido, fallo el refresco
    val instanteDeLosDatos: Instant? = null,
)

val hayQueOcuparLaPantalla = contenido == null

La regla de presentación que se deriva es sencilla de enunciar y disciplina todas las decisiones posteriores: un error solo puede ocupar la pantalla entera si no hay absolutamente nada que mostrar. Si hay contenido, por antiguo que sea, el fallo se comunica sin destruirlo, mediante un aviso efímero con acción de reintento o una marca de última actualización. Esa única regla elimina de un plumazo la peor experiencia posible, que es perder lo que ya se estaba leyendo porque una petición de fondo no llegó.

Hay además un caso que se confunde sistemáticamente con el error y que exige un tratamiento propio: el vacío legítimo. Una lista sin resultados porque el filtro es demasiado estrecho no es un fallo, y presentarla con la iconografía del error enseña al usuario a desconfiar de una aplicación que funciona. Vacío y error son estados distintos con mensajes distintos y acciones distintas.

flowchart TD
A[Resultado de la operacion] --> B{Hubo error}
B -->|No| C{La coleccion viene vacia}
C -->|Si| D[Estado vacio con mensaje propio]
C -->|No| E[Contenido con marca de frescura]
B -->|Si| F{Habia contenido previo}
F -->|Si| G[Mantener contenido y avisar sin bloquear]
F -->|No| H{El error es reintentable}
H -->|Si| I[Pantalla de error con accion de reintento]
H -->|No| J[Pantalla de error con accion especifica]
style E fill:#a6e3a1,color:#11111b
style G fill:#f9e2af,color:#11111b
style I fill:#f38ba8,color:#11111b

Recuperación: cada error pide una acción distinta

Un botón de reintentar debajo de cualquier mensaje es la versión visual del bloque de captura genérico. Si la sesión caducó, reintentar no arregla nada y lo que hace falta es llevar al usuario a identificarse. Si el servidor pidió bajar el ritmo, reintentar de inmediato empeora la situación y lo correcto es esperar el margen indicado, idealmente sin pedirle nada al usuario. Si la validación falló, la acción está en el formulario y no en la red. Si el contrato se rompió, no hay acción de usuario posible y la única respuesta honesta es reconocer que el fallo es del equipo mientras la telemetría recoge el detalle.

De ahí se sigue que la propia interfaz de reintento debe ser una función del error y no un botón universal. Un tipo cerrado permite expresarlo sin ambigüedad, porque cada variante puede declarar qué acción ofrece, y el compilador exigirá una respuesta cuando mañana aparezca una variante nueva.

fun ErrorRed.accion(): Accion = when (this) {
    ErrorRed.SinConexion, ErrorRed.TiempoAgotado -> Accion.Reintentar
    is ErrorRed.Servidor -> Accion.ReintentarMasTarde
    ErrorRed.SesionCaducida -> Accion.Identificarse
    is ErrorRed.DemasiadasPeticiones -> Accion.EsperarAutomatico(esperaSegundos)
    is ErrorRed.Validacion -> Accion.CorregirFormulario(campos)
    is ErrorRed.SinPermiso, ErrorRed.NoEncontrado -> Accion.Ninguna
    is ErrorRed.ContratoRoto -> Accion.ReportarYRegistrar
}

Tres mecanismos complementan a la acción explícita y en conjunto reducen mucho más los errores visibles que cualquier mejora del mensaje. El primero es servir desde almacenamiento local antes de pedir a la red, de forma que la pantalla nazca con contenido y la petición sea siempre un refresco: convierte la mayoría de los fallos de bloqueantes en transitorios. El segundo es reanudar automáticamente cuando la conectividad vuelve, observando el estado de la red para relanzar lo que quedó pendiente sin que el usuario tenga que tocar nada. El tercero es diferir las escrituras: encolarlas de forma persistente y ejecutarlas cuando haya red, con lo que una acción del usuario deja de depender de que en ese instante exacto hubiera cobertura.

ℹ️
Medir lo que el usuario sufre, no lo que el servidor promete

Los paneles de servidor miden latencia y tasa de error del lado que responde, y por construcción no ven las peticiones que nunca llegaron. La experiencia real solo se mide desde el cliente: proporción de operaciones fallidas por familia, tiempo hasta el primer contenido, cuántos éxitos fueron en realidad reintentos y qué porcentaje de sesiones vieron una pantalla de error bloqueante. Es habitual encontrar un noventa y nueve coma nueve por ciento de disponibilidad en el servidor y varios puntos porcentuales de fallo percibido en el cliente, y ambas cifras son correctas.

El fallo no es una desviación del camino feliz: es la mitad del producto

La forma en que un equipo trata los errores de red revela, mejor que cualquier documento de arquitectura, qué modelo mental tiene de su propio producto. El modelo dominante, que casi nunca se enuncia porque se da por evidente, es que existe un camino correcto por el que discurre la aplicación y una serie de desviaciones desafortunadas que hay que contemplar por completitud. Ese modelo se refleja en el código con una precisión casi cómica: el camino feliz ocupa el cuerpo de la función y está escrito con cuidado, mientras que todo lo demás se acumula al final, en un bloque de captura donde media docena de sucesos incomparables terminan produciendo la misma cadena de texto. Y se refleja también en el reparto del trabajo de diseño, porque en la enorme mayoría de los productos las pantallas de error son las únicas que nadie diseñó: se resolvieron en el momento de programarlas, por la persona que tenía prisa, con el texto que se le ocurrió. El problema de ese modelo es que describe mal la realidad. En un dispositivo móvil, la aplicación pasa una fracción sustancial de su vida en condiciones que el camino feliz no contempla, y en esa fracción se concentra desproporcionadamente el abandono, la reseña negativa y la desconfianza. Un usuario que ve una lista completa no aprende nada sobre tu aplicación; un usuario que ve una pantalla en blanco con una frase genérica aprende que no es fiable, y ese aprendizaje sobrevive a muchas sesiones buenas posteriores. De ahí se sigue el cambio de encuadre que ordena todo lo demás. Los estados degradados no son excepciones del producto: son estados del producto, con la misma categoría que cualquier otro, y merecen la misma cantidad de diseño, de tipos y de pruebas. Cuando se acepta esa premisa, las decisiones técnicas de esta lección dejan de parecer refinamientos opcionales y se vuelven consecuencias necesarias. El tipo cerrado existe porque cada estado degradado es distinto y hay que distinguirlos. El estado de pantalla separa contenido de actividad porque tener datos antiguos mientras falla el refresco es una situación normal y frecuente, no un caso raro. El almacenamiento local no es una optimización de rendimiento sino la manera de que la ausencia de red deje de ser un suceso catastrófico. Y la telemetría del cliente existe porque, si no se mide lo que el usuario sufre, la única fuente de información sobre la mitad degradada del producto son las reseñas de una estrella, que llegan tarde, sin contexto y sin traza.

⚔️ Somete tu aplicación a la red real
  1. Enumera todos los bloques de captura de tu capa de datos y comprueba cuántos capturan la jerarquía general de excepciones y se tragan la cancelación.
  2. Sustituye la propagación de excepciones de biblioteca por un tipo cerrado propio y verifica que ningún archivo de la capa de presentación importa nada de la biblioteca de red.
  3. Reproduce cada familia de fallo por separado —modo avión, servidor detenido, respuesta con campo renombrado, límite de peticiones— y anota qué ve exactamente el usuario en cada caso.
  4. Rediseña el estado de una pantalla con datos para que un refresco fallido no destruya el contenido anterior, y añade una marca visible de última actualización.
  5. Conecta el reintento automático a la reaparición de la conectividad y mide cuántos errores visibles desaparecen sin que el usuario haga nada.