Lidiar con los fallos en el entorno asíncrono de Kotlin puede volverse un auténtico quebradero de cabeza si no se entiende cómo fluyen los errores. A menudo, una pequeña excepción en una tarea secundaria puede provocar el cierre inesperado de toda la aplicación o, peor aún, quedar silenciada, dejándonos con bugs difíciles de rastrear que nos hacen perder horas de sueño.
Para evitar estos sustos, es fundamental dominar la concurrencia estructurada y saber exactamente qué herramienta utilizar según el escenario. Desde el uso de controladores globales hasta enfoques funcionales más modernos, existen diversas formas de asegurar la estabilidad de nuestro software sin llenar el código de bloques try-catch que resultan molestos a la vista.
La propagación de errores y los constructores de corrutinas
En Kotlin, no todos los constructores de corrutinas se comportan igual cuando las cosas salen mal. Por un lado, tenemos launch, que propaga las excepciones de forma automática. Si una corrutina creada con launch lanza un error y no se gestiona, este se trata como una excepción no capturada, muy similar a lo que ocurre con el manejador de hilos estándar de Java.
Por otro lado, el constructor async expone la excepción al usuario. Esto significa que el error no se lanza inmediatamente, sino que queda encapsulado en el objeto Deferred. El fallo saltará únicamente cuando intentemos llamar a await(), momento en el cual el desarrollador debe estar preparado para capturar el error mediante un bloque tradicional de captura.
Es importante mencionar que existe una jerarquía clara. Cuando una corrutina hija falla con algo que no sea una CancellationException, cancela automáticamente a su padre. Este comportamiento es la base de la concurrencia estructurada, garantizando que si una parte esencial del proceso falla, el resto de la jerarquía no quede en un estado inconsistente.
Dominando el CoroutineExceptionHandler
Cuando necesitamos un mecanismo genérico para registrar fallos o avisar al usuario sin detener todo el flujo, entra en juego el CoroutineExceptionHandler. Este elemento de contexto actúa como un bloque catch global para una corrutina raíz y todos sus descendientes, permitiéndonos centralizar la gestión de logs o reiniciar la aplicación si es necesario.
Sin embargo, hay que tener cuidado: este manejador solo se activa para excepciones que no han sido capturadas por ningún otro medio. Además, no sirve para recuperar la ejecución, ya que cuando el handler es invocado, la corrutina ya ha finalizado su ciclo de vida. Un detalle clave es que no tiene efecto en async, puesto que este constructor siempre guarda la excepción en el Deferred.
Si trabajamos con GlobalScope, que es una API delicada y debe usarse con precaución, el manejador es vital para evitar que el proceso muera abruptamente. En aplicaciones Android, es muy común adjuntar este handler al viewModelScope para evitar que errores de red inesperados provoquen que la app se cierre en la cara del usuario.
Supervisión y el control de fallos independientes
A veces necesitamos que las tareas sean independientes; es decir, que si una petición falla, las demás sigan su camino sin inmutarse. Para esto existe el SupervisorJob. A diferencia de un Job estándar, la cancelación aquí solo se propaga hacia abajo, impidiendo que el fallo de un hijo aniquile al padre y a sus hermanos.
Si queremos aplicar esta lógica a un bloque de código específico, podemos utilizar el supervisorScope. Este entorno permite que las corrutinas lanzadas en su interior gestionen sus propios errores. De hecho, en este escenario, las corrutinas lanzadas directamente dentro del scope sí pueden aprovechar el CoroutineExceptionHandler instalado en su contexto.
Un caso típico sería una pantalla de detalles que carga datos de varias fuentes. Usando un scope de supervisión, si el servicio de «comentarios» falla, el contenido principal de la página se seguirá mostrando correctamente, mejorando drásticamente la experiencia de usuario.
Enfoque funcional: runCatching y la clase Result
Si estamos hartos de los bloques try-catch anidados, Kotlin nos ofrece una alternativa mucho más elegante: la función runCatching. Esta herramienta ejecuta un bloque de código y envuelve el resultado en un objeto de tipo Result, que puede representar ya sea un éxito con un valor o un fallo con una excepción.
La magia de Result reside en su capacidad de composición y transformación. Podemos encadenar operaciones usando funciones como map, flatMap o recover. Esto permite separar la lógica de negocio del manejo de errores, creando un flujo de datos donde el error se trata como un valor más que fluye por el sistema.
Para implementar esto de forma profesional, se recomienda el patrón de repositorio. En lugar de lanzar excepciones que rompan la pila, la función del repositorio devuelve un Result con el dato. Así, la capa de UI puede decidir de forma explícita cómo mostrar el error basándose en el tipo de fallo capturado.
Gestión de errores en Flows y WorkManager
Cuando trabajamos con flujos de datos fríos, un error dentro de la recolección detendría por completo el stream. Para solucionar esto, el operador .catch {} es la herramienta definitiva. Este operador permite interceptar el fallo y emitir un valor por defecto o simplemente registrar el problema sin que la aplicación colapse.
En el ámbito de las tareas en segundo plano con WorkManager, el uso de CoroutineWorker requiere una atención especial. Si una tarea falla, no se reintentará automáticamente a menos que devolvamos explícitamente Result.retry(). Envolver la lógica de trabajo en un try-catch y retornar este estado es la única forma de garantizar que el sistema reintente la tarea según la política configurada.
Para errores transitorios, como microcortes de internet, es muy útil implementar un reintento con backoff exponencial. Esto consiste en intentar la operación varias veces, aumentando el tiempo de espera entre cada intento, evitando así saturar el servidor y dando tiempo a que la conexión se estabilice.
Lograr una aplicación robusta implica combinar la concurrencia estructurada con el control preciso de la propagación de fallos. El uso inteligente de SupervisorJob y CoroutineExceptionHandler evita cierres inesperados, mientras que runCatching y los operadores de Flow aportan una limpieza visual y conceptual al código, permitiendo que el desarrollador anticipe los errores y los gestione de manera fluida y predecible.
Continúar leyendo...