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 suJob, como viste en Un alcance propio, donde se explicaCoroutineScope(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()ySupervisorJob(): son elJobque hace de padre de un alcance. ConJob(), el fallo de una hija cancela al padre y este a las demás; conSupervisorJob(), no.Throwable: el tipo más general de error, que ya viste en Result. Una función que recibe «unThrowable» recibe cualquier error.+: une elementos de contexto en uno solo:Dispatchers.Default + trabajo + manejadores 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:
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 programaLeo 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ón | Qué pasa |
|---|---|
Hijas en un coroutineScope normal | Una falla y las hermanas se cancelan. El error se relanza a quien esperaba. |
Hijas en un supervisorScope o bajo un SupervisorJob | Cada una falla por su cuenta. Las hermanas siguen. |
Una corrutina raíz lanzada con launch | El 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):
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.
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: 2Para 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
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.
Qué hacer en cada fichero (el código completo está más abajo):
- Crea
Errores.kt. Entorneo → kotlin+java, clic derecho sobre el paquetecom.sebastiannarvaez.marcador.torneo→ New → Kotlin Class/File → File → escribeErroresy pulsa Intro. Dentro van la excepciónSuspendidoPorApagon,jugarConApagonyjugarJornadaSupervisada. Los comentarios del bloque de abajo están ajustados para ti. - Abre
Cronometro.kt(doble clic en su nodo, en el mismo paquete) y haz dos cambios: añade los dosimportnuevos (CoroutineExceptionHandlerySupervisorJob) junto a los que ya hay arriba, y sustituye la claseTransmisorentera, 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 elimport. - Abre
Main.kty haz dos cambios: la llamadademoErrores()al final del bloquerunBlockingdemain, trasdemoDispatchers(), y la funcióndemoErrores()justo debajo de la llave que cierramain. - El test se copia con el botón, al final. Se llama
ErroresEnCorrutinasTestporqueErroresTestya existe desde Ficheros y depurador.
Cuatro cosas antes de escribir:
jugarConApagonse parece ajugarPartido(recibepartidoymsPorMinuto) y añademinutoDelApagon. No usa su parámetropartido: está para que su forma encaje enjugar: suspend (Partido) -> Partido, el tipo de función que recibejugarJornadaSupervisada. Por eso Android Studio lo marca en amarillo: es normal.Transmisorrecibe dos parámetros con valor por defecto,alFallaryjugar, para poder inyectar el partido que falla (pasárselo desde fuera). Las llamadas anteriores,Transmisor(), siguen compilando.SupervisorJob()sustituye aJob(), ymanejadorse suma al contexto con+.- Los partidos de
jugarJornadaSupervisadacorren en el hilo de quien llama (enrunBlocking, uno solo): concurrencia. Los delTransmisorcorren enDispatchers.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é:
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 servidoIntenta escribirlo tú, en este orden:
Errores.kt.SuspendidoPorApagones 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.- 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 conalFallary las demás siguen; el transmisor sigue activo. demoErrores()juega el Rayo FC-Toros y un Lobos-Águilas que sufre un apagón en el minuto 30, primero conjugarJornadaSupervisada(mostrando cómo terminó o por qué falló cada uno; para distinguir el partido que falla, comparapartido.local === Ejemplo.lobos) y después con unTransmisor(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: trueCuando termines, abre el bloque y compara.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
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.
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.SupervisorJob// 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.
println("== un partido que falla no tumba al otro ==")
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.
- En
torneo → kotlin+java, clic derecho sobre el paquetecom.sebastiannarvaez.marcador.torneo (test)→ New → Kotlin Class/File → File → escribeErroresEnCorrutinasTesty pulsa Intro. - Pulsa Copiar fichero en el bloque de abajo.
- En el editor, selecciona todo y pega: Cmd+A / Cmd+V. El texto copiado trae su propia línea
package.
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:runtermina enBUILD SUCCESSFULe 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
Transmisorsigue activo. ./gradlew :torneo:testtermina enBUILD SUCCESSFULcon 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.Dos
asyncdentro de uncoroutineScopenormal: uno lanza una excepción. ¿Qué le pasa al otro?2.Envuelves
alcance.launch { jugarConApagon(...) }.join()en untry/catch. ¿Qué ocurre cuando el partido falla?3.¿Qué puede hacer un
CoroutineExceptionHandler?4.En
jugarJornadaSupervisada, ¿por qué se envuelve cadaawait()conintentaren lugar de envolver elasync?5.Lanzas dos partidos con un
launchcada uno, hijos derunBlocking, y uno lanza una excepción sin handler. ¿Cuál es el resultado?