Saltar al contenido
K
Corrutinas y FlowPaso a paso40 min de lectura · 6 min de máquina Básico

Errores en corrutinas

Al terminar: Manejar errores con try/catch, CoroutineExceptionHandler, SupervisorJob y supervisorScope.

Al terminar esta lección, un partido que falla a mitad —se va la luz del campo en el minuto 30— no tumbará al partido que se juega a la vez. Verás en consola cómo uno termina 2-1 mientras el otro informa de su fallo, y sabrás elegir entre cuatro herramientas para manejar errores en corrutinas.

Importa porque en corrutinas un error no se comporta como en código normal: sube por la jerarquía, cancela a las hermanas y, si nadie lo recoge, puede llegar a la consola sin que el programa se entere. Temas: try/catch, CoroutineExceptionHandler, SupervisorJob y supervisorScope.

Cómo viaja un error entre corrutinas

Cuando una corrutina falla, el error sube a su padre, y el padre cancela a todas las demás hijas. Es la concurrencia estructurada de Alcance, Job y cancelación (todo trabajo vive dentro de un alcance y no queda huérfano), aplicada a los errores: un grupo entero se cae junto.

Seis términos que salen en la tabla y en el código:

  • Corrutina raíz (root): una corrutina sin padre corrutina. Se lanza directamente desde un alcance que creaste tú, no desde dentro de otra corrutina.
  • CoroutineScope(contexto): crea un alcance propio con ese contexto. El contexto es el conjunto de datos de la corrutina, con su dispatcher y su Job, como viste en Un alcance propio, donde se explica CoroutineScope(contexto) y el +.
  • Handler (manejador): una función que recibe el error de una corrutina que ha fallado. En Kotlin se escribe con CoroutineExceptionHandler.
  • Job() y SupervisorJob(): son el Job que hace de padre de un alcance. Con Job(), el fallo de una hija cancela al padre y este a las demás; con SupervisorJob(), no.
  • Throwable: el tipo más general de error, que ya viste en Result. Una función que recibe «un Throwable» recibe cualquier error.
  • +: une elementos de contexto en uno solo: Dispatchers.Default + trabajo + manejador es un contexto con dispatcher, padre y handler.

El caso más sencillo, para verlo funcionar. Dos repartidores salen a la vez y uno se queda sin gasolina a los 50 ms:

ejemplo mínimo: un fallo tumba al grupo (con comentarios)
import kotlinx.coroutines.*
 
// Dos repartidores salen a la vez; uno se queda sin gasolina a los 50 ms
suspend fun repartir(nombre: String, ms: Long, falla: Boolean): String {
    delay(ms)                                               // el reparto tarda ms milisegundos
    if (falla) throw IllegalStateException("$nombre: sin gasolina")
    return "$nombre entregó"
}
 
fun main() = runBlocking {
    try {
        coroutineScope {                                    // un grupo normal: atados con la misma cuerda
            val lento = async { repartir("Leo", 200, falla = false) }
            val roto = async { repartir("Ana", 50, falla = true) }
            println(roto.await())                           // aquí llega el error de Ana…
            println(lento.await())                          // …y esta línea ya no se ejecuta
        }
    } catch (e: IllegalStateException) {
        println("El grupo falló: ${e.message}")             // el error sale del coroutineScope
    }
    println("Fin del programa")
}
El grupo falló: Ana: sin gasolina
Fin del programa

Leo no llegó a entregar: cuando Ana falló, el grupo lo canceló. Y el try/catch funciona porque está alrededor del coroutineScope, que es quien relanza el error. Eso da tres situaciones:

SituaciónQué pasa
Hijas en un coroutineScope normalUna falla y las hermanas se cancelan. El error se relanza a quien esperaba.
Hijas en un supervisorScope o bajo un SupervisorJobCada una falla por su cuenta. Las hermanas siguen.
Una corrutina raíz lanzada con launchEl error no vuelve a quien la lanzó: va al CoroutineExceptionHandler, si lo hay.

Las cuatro herramientas

1. try/catch, pero en el sitio correcto. Funciona alrededor de código que suspende y devuelve (await(), una función suspend): el error viaja hasta quien espera. No funciona alrededor de launch, ni siquiera si esperas con join(): join() espera a que la corrutina termine, pero no relanza el error de la hija. El error no vuelve por ahí: va al handler (o a la consola si no hay).

Aquí alcance y jugarConApagon son los que escribirás en el paso de abajo (un alcance propio y un partido que falla a mitad):

así no vale: try con catch alrededor de launch
try {
    alcance.launch { jugarConApagon(partido, minutoDelApagon = 5) }.join()
} catch (e: Exception) {
    // Nunca se ejecuta: join() espera, pero no relanza. El error va al handler del alcance.
}

2. CoroutineExceptionHandler, para las corrutinas raíz (root): las que cuelgan directamente de un alcance propio. Es la red de seguridad, no un lugar para arreglar nada.

3. SupervisorJob, para un alcance propio cuyas hijas no deben arrastrarse.

4. supervisorScope, el equivalente de coroutineScope pero con esa regla, para agrupar trabajo dentro de una función.

ejemplo mínimo: supervisorScope con async (con comentarios)
import kotlinx.coroutines.*
 
suspend fun dosTareas(): List<Result<Int>> = supervisorScope {
    val buena = async { 1 }
    // `<Int>` va a mano: el bloque solo lanza un error y Kotlin no puede deducir qué devolvería.
    val mala = async<Int> { error("se rompió") }
    // Cada await se envuelve por separado: el fallo de `mala` no impide leer `buena`.
    // runCatching atrapa el fallo y lo guarda en un Result (en Marcador, `intentar`, que además relanza la cancelación).
    listOf(buena, mala).map { runCatching { it.await() } }
}
 
fun main() = runBlocking {
    println(dosTareas())
}
[Success(1), Failure(java.lang.IllegalStateException: se rompió)]

Se prueba tal cual en el Kotlin Playground. Aquí runCatching vale porque no hay nada que cancelar.

Con async, el error llega al hacer await(): por eso se envuelve el await, no el async.

Reto · Ejercicio: un fallo que no te tumbe la jornada

En el Kotlin Playground, fuera de tu proyecto, que ya trae la librería de corrutinas.

Simula una descarga que tarda 50 ms y falla con el mensaje «fallo» y su número si ese número es par. Descarga a la vez las cinco primeras (del 1 al 5) de modo que un fallo no cancele a las demás, y quédate con el resultado o el error de cada una, en orden. Imprime «Bien: …» o «Mal: …» por cada una y, al final, el recuento. La consola debe mostrar:

Bien: descarga 1
Mal: fallo 2
Bien: descarga 3
Mal: fallo 4
Bien: descarga 5
Éxitos: 3, fallos: 2

Para pensar: si cambias la herramienta que impide que un fallo cancele a las hermanas por coroutineScope, ¿qué pasa con las demás descargas? La respuesta está en la solución.

Ver solución
solución: descargas supervisadas
import kotlinx.coroutines.*
 
suspend fun descargar(id: Int): String {
    delay(50)
    if (id % 2 == 0) throw IllegalStateException("fallo $id")
    return "descarga $id"
}
 
suspend fun descargarTodas(ids: List<Int>): List<Result<String>> = supervisorScope {
    val trabajos = ids.map { async { descargar(it) } }
    // El await se envuelve por separado, y la CancellationException se relanza.
    trabajos.map {
        try {
            Result.success(it.await())
        } catch (cancelacion: CancellationException) {
            throw cancelacion
        } catch (error: Exception) {
            Result.failure(error)
        }
    }
}
 
fun main() = runBlocking {
    val resultados = descargarTodas(listOf(1, 2, 3, 4, 5))
    resultados.forEach { r ->
        r.onSuccess { println("Bien: $it") }
         .onFailure { println("Mal: ${it.message}") }
    }
    println("Éxitos: ${resultados.count { it.isSuccess }}, fallos: ${resultados.count { it.isFailure }}")
}

Con coroutineScope, en cuanto falla la descarga 2, las demás se cancelan y el error se relanza: no llegas a obtener ningún resultado, ni siquiera de las que iban bien.

Paso · Un partido que falla no tumba al otro

Objetivo. Crear Errores.kt con una excepción propia y una jornada supervisada; convertir el Transmisor de la lección de alcances en uno con SupervisorJob y handler; y ver en consola que Rayo-Toros termina 2-1 mientras Lobos-Águilas falla en el minuto 30.

Esta lección no instala nada: SupervisorJob, supervisorScope y CoroutineExceptionHandler vienen en la librería de corrutinas que añadiste en Corrutinas y suspend.

torneo → kotlin+java → com.sebastiannarvaez.marcador.torneo
Errores.ktse crea
Cronometro.ktse modifica
Main.ktse modifica
torneo → kotlin+java → com.sebastiannarvaez.marcador.torneo (test)
ErroresEnCorrutinasTest.ktse crea
Uno nuevo, dos modificados y el test, tal como los ves en la vista Android.

Qué hacer en cada fichero (el código completo está más abajo):

  1. Crea Errores.kt. En torneo → kotlin+java, clic derecho sobre el paquete com.sebastiannarvaez.marcador.torneo → New → Kotlin Class/File → File → escribe Errores y pulsa Intro. Dentro van la excepción SuspendidoPorApagon, jugarConApagon y jugarJornadaSupervisada. Los comentarios del bloque de abajo están ajustados para ti.
  2. Abre Cronometro.kt (doble clic en su nodo, en el mismo paquete) y haz dos cambios: añade los dos import nuevos (CoroutineExceptionHandler y SupervisorJob) junto a los que ya hay arriba, y sustituye la clase Transmisor entera, con el comentario que lleva encima, por la nueva. El resto del fichero no cambia. Si prefieres, pulsa Option+Enter sobre la palabra en rojo para que Android Studio añada el import.
  3. Abre Main.kt y haz dos cambios: la llamada demoErrores() al final del bloque runBlocking de main, tras demoDispatchers(), y la función demoErrores() justo debajo de la llave que cierra main.
  4. El test se copia con el botón, al final. Se llama ErroresEnCorrutinasTest porque ErroresTest ya existe desde Ficheros y depurador.

Cuatro cosas antes de escribir:

  • jugarConApagon se parece a jugarPartido (recibe partido y msPorMinuto) y añade minutoDelApagon. No usa su parámetro partido: está para que su forma encaje en jugar: suspend (Partido) -> Partido, el tipo de función que recibe jugarJornadaSupervisada. Por eso Android Studio lo marca en amarillo: es normal.
  • Transmisor recibe dos parámetros con valor por defecto, alFallar y jugar, para poder inyectar el partido que falla (pasárselo desde fuera). Las llamadas anteriores, Transmisor(), siguen compilando.
  • SupervisorJob() sustituye a Job(), y manejador se suma al contexto con +.
  • Los partidos de jugarJornadaSupervisada corren en el hilo de quien llama (en runBlocking, uno solo): concurrencia. Los del Transmisor corren en Dispatchers.Default, hilos distintos: pueden avanzar en paralelo real.

Piezas nuevas de este paso: una excepción propia con un dato extra; supervisorScope (ya lo viste arriba); un parámetro de tipo suspend (X) -> X con una función por defecto; CoroutineExceptionHandler { _, error -> … }; un parámetro alFallar: (Throwable) -> Unit; ::servir, que pasa la función servir como valor; y un alcance con SupervisorJob() y el handler sumados con +. De throw ya sabes de Excepciones: try, catch y require, y de onSuccess y onFailure de Result. Las piezas que faltan, en una cafetería que se queda sin té:

ejemplo mínimo: un handler y un SupervisorJob (con comentarios)
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
 
// Una excepción propia: «: Exception(mensaje)» la hace excepción; además guarda un dato
class SinStock(val producto: String) : Exception("No queda $producto")
 
suspend fun servir(producto: String): String {
    delay(10)
    if (producto == "té") throw SinStock(producto)
    return "$producto servido"
}
 
// preparar: suspend (String) -> String, una función suspendida que se pasa como lambda
class Cafeteria(
    alFallar: (Throwable) -> Unit,                     // recibe el error y no devuelve nada
    private val preparar: suspend (String) -> String = ::servir,   // por defecto, la de arriba
) {
    // El handler recibe el contexto (no se usa: _) y el error de una corrutina raíz
    private val manejador = CoroutineExceptionHandler { _, error -> alFallar(error) }
    // SupervisorJob: si una corrutina falla, el alcance sigue vivo
    private val alcance = CoroutineScope(Dispatchers.Default + SupervisorJob() + manejador)
 
    fun pedir(producto: String) = alcance.launch { println(preparar(producto)) }
}
 
fun main() = runBlocking {
    val cafeteria = Cafeteria(alFallar = { println("Aviso del handler: ${it.message}") })
    cafeteria.pedir("té").join()                           // falla: lo recoge el handler
    cafeteria.pedir("zumo").join()                         // el alcance sigue sirviendo
}

Al ejecutarlo, imprime:

Aviso del handler: No queda té
zumo servido

Intenta escribirlo tú, en este orden:

  1. Errores.kt. SuspendidoPorApagon es un error propio que recuerda el minuto y dice «Partido suspendido en el minuto 30: se fue la luz». jugarConApagon(partido, msPorMinuto, minutoDelApagon) espera hasta ese minuto y falla con ese error. jugarJornadaSupervisada(partidos, jugar) juega todos los partidos a la vez, con la forma de jugar que le pasen (por defecto, jugarPartido), y devuelve para cada uno su resultado o su error: el fallo de uno no cancela a los demás.
  2. El Transmisor, que ya tenías, recibe ahora dos cosas opcionales: qué hacer cuando una transmisión falla (alFallar) y cómo se juega un partido (jugar, por defecto con cronómetro). Si una transmisión falla, avisa con alFallar y las demás siguen; el transmisor sigue activo.
  3. demoErrores() juega el Rayo FC-Toros y un Lobos-Águilas que sufre un apagón en el minuto 30, primero con jugarJornadaSupervisada (mostrando cómo terminó o por qué falló cada uno; para distinguir el partido que falla, compara partido.local === Ejemplo.lobos) y después con un Transmisor (mostrando el aviso del fallo, el partido que termina y si el transmisor sigue activo).

Tras las demos anteriores, debe verse:

== un partido que falla no tumba al otro ==
Terminó Rayo FC-Toros: 2-1
Falló: Partido suspendido en el minuto 30: se fue la luz
  handler: Partido suspendido en el minuto 30: se fue la luz
  terminó Rayo FC
Transmisor sigue activo tras el fallo: true

Cuando termines, abre el bloque y compara.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Errores.kt
package com.sebastiannarvaez.marcador.torneo
 
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.supervisorScope
 
// Algo que sale mal en mitad de un partido: se va la luz del campo.
class SuspendidoPorApagon(val minuto: Int) : Exception("Partido suspendido en el minuto $minuto: se fue la luz")
 
// Un partido que se juega hasta el minuto del apagón y ahí falla.
suspend fun jugarConApagon(partido: Partido, msPorMinuto: Long = 10, minutoDelApagon: Int): Partido {
    delay(msPorMinuto * minutoDelApagon)
    throw SuspendidoPorApagon(minutoDelApagon)
}
 
// En un `coroutineScope` normal, si una hija falla, el alcance cancela a todas
// las hermanas y relanza el error: un partido roto tumba la jornada.
// `supervisorScope` cambia esa regla: cada hija falla POR SU CUENTA y las
// demás siguen. El error de cada una llega al hacer `await()` sobre ella.
//
// El resultado es un Result por partido, en el mismo orden. `intentar` (la que
// escribiste en Alcance, Job y cancelación) convierte el error en valor sin tragarse la CancellationException.
suspend fun jugarJornadaSupervisada(
    partidos: List<Partido>,
    jugar: suspend (Partido) -> Partido = { jugarPartido(it) },
): List<Result<Partido>> = supervisorScope {
    val jugando = partidos.map { partido -> async { jugar(partido) } }
    jugando.map { intentar { it.await() } }
}

En Cronometro.kt se añaden dos imports y se sustituye la clase Transmisor. El resto del fichero no cambia.

Cronometro.kt: imports nuevos
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.SupervisorJob
Cronometro.kt: el Transmisor nuevo
// Un alcance PROPIO: la clase decide cuándo empieza y cuándo acaba la vida de
// todo lo que lanza. El Job del alcance es el padre de todas las corrutinas;
// cancelarlo las cancela a todas. Al pitar el final, `pitarElFinal()`.
//
// Con `SupervisorJob`, un partido que falla no cancela al alcance ni a los
// demás partidos. Su error llega al CoroutineExceptionHandler, que aquí avisa
// con `alFallar`. Sin handler, un error en un `launch` de la raíz acaba en la
// consola como «Exception in thread …».
class Transmisor(
    private val alFallar: (Throwable) -> Unit = {},
    private val jugar: suspend (Partido, Long) -> Partido = { partido, ms -> jugarConCronometro(partido, ms) },
) {
    private val trabajo = SupervisorJob()
    private val manejador = CoroutineExceptionHandler { _, error -> alFallar(error) }
    private val alcance = CoroutineScope(Dispatchers.Default + trabajo + manejador)
 
    val activo: Boolean get() = trabajo.isActive
 
    fun transmitir(partido: Partido, msPorMinuto: Long = 10, alFinal: (Partido) -> Unit = {}): Job =
        alcance.launch { alFinal(jugar(partido, msPorMinuto)) }
 
    fun pitarElFinal() = trabajo.cancel()
}

En Main.kt se añade la llamada al final del bloque runBlocking de main y la función demoErrores(). El resto del fichero no cambia.

Main.kt: la llamada
        println("== un partido que falla no tumba al otro ==")
        demoErrores()
Main.kt: demoErrores
suspend fun demoErrores() {
    val resultados = jugarJornadaSupervisada(listOf(Ejemplo.rayoContraToros, Ejemplo.lobosContraAguilas)) { partido ->
        if (partido.local === Ejemplo.lobos) jugarConApagon(partido, minutoDelApagon = 30) else jugarPartido(partido)
    }
    for (resultado in resultados) {
        resultado
            .onSuccess { println("Terminó ${it.local.nombre}-${it.visitante.nombre}: ${it.golesLocal}-${it.golesVisitante}") }
            .onFailure { println("Falló: ${it.message}") }
    }
 
    // El mismo caso con un alcance propio: el error de un `launch` raíz no llega a
    // quien lo lanzó, llega al CoroutineExceptionHandler.
    val transmisor = Transmisor(alFallar = { println("  handler: ${it.message}") }, jugar = { partido, ms ->
        if (partido.local === Ejemplo.lobos) jugarConApagon(partido, ms, minutoDelApagon = 30) else jugarPartido(partido, ms)
    })
    listOf(Ejemplo.rayoContraToros, Ejemplo.lobosContraAguilas)
        .map { transmisor.transmitir(it) { final -> println("  terminó ${final.local.nombre}") } }
        .forEach { it.join() }
    println("Transmisor sigue activo tras el fallo: ${transmisor.activo}")
    transmisor.pitarElFinal()
}

El test: se copia con el botón

ErroresEnCorrutinasTest.kt (5 pruebas) comprueba lo que acabas de escribir y se copia con el botón Copiar fichero, como en Clases y objetos.

  1. En torneo → kotlin+java, clic derecho sobre el paquete com.sebastiannarvaez.marcador.torneo (test) → New → Kotlin Class/File → File → escribe ErroresEnCorrutinasTest y pulsa Intro.
  2. Pulsa Copiar fichero en el bloque de abajo.
  3. En el editor, selecciona todo y pega: Cmd+A / Cmd+V. El texto copiado trae su propia línea package.
ErroresEnCorrutinasTest.kt

El test conCoroutineScopeElFalloSiTumbaALaHermana es el que enseña el contraste: con un coroutineScope normal, la hermana termina cancelada. Lo copias y lo lees; no hace falta que entiendas cada línea.

Lo que has visto

jugarJornadaSupervisada usa supervisorScope y intentar: el primero evita que el fallo de un partido cancele al otro; el segundo lo convierte en un Result por partido, en el mismo orden que la lista. El test conCoroutineScopeElFalloSiTumbaALaHermana demuestra el contraste: con un coroutineScope normal, la hermana termina con isCancelled == true y se relanza SuspendidoPorApagon.

El Transmisor hace lo mismo para trabajo lanzado, no esperado: no hay await donde atrapar el error, así que lo recoge el handler. Y el handler no arregla nada: solo avisa.

Punto de control

  • En la Terminal de Android Studio, ./gradlew :torneo:run termina en BUILD SUCCESSFUL e imprime este bloque:
== un partido que falla no tumba al otro ==
Terminó Rayo FC-Toros: 2-1
Falló: Partido suspendido en el minuto 30: se fue la luz
  handler: Partido suspendido en el minuto 30: se fue la luz
  terminó Rayo FC
Transmisor sigue activo tras el fallo: true
  • Si escribiste otro título, saldrá el tuyo. Lo que debe cumplirse es el resto: un partido termina 2-1, el otro informa del apagón y el Transmisor sigue activo.
  • ./gradlew :torneo:test termina en BUILD SUCCESSFUL con 106 pruebas. El fichero tiene cinco pruebas, pero Gradle cuenta cuatro: una devuelve un valor y JUnit 5 la ignora sin avisar (lo descubrirás al migrar a Kotlin Multiplatform). Las 102 que ya tenías más esas cuatro dan 106.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.Dos async dentro de un coroutineScope normal: uno lanza una excepción. ¿Qué le pasa al otro?

  2. 2.Envuelves alcance.launch { jugarConApagon(...) }.join() en un try/catch. ¿Qué ocurre cuando el partido falla?

  3. 3.¿Qué puede hacer un CoroutineExceptionHandler?

  4. 4.En jugarJornadaSupervisada, ¿por qué se envuelve cada await() con intentar en lugar de envolver el async?

  5. 5.Lanzas dos partidos con un launch cada uno, hijos de runBlocking, y uno lanza una excepción sin handler. ¿Cuál es el resultado?

Faltan 5 de 5