Код, который пишут примерно все и примерно один раз:
try {
scope.launch {
repository.save(item)
}
} catch (e: IOException) {
showError() // сюда никогда ничего не прилетит
}
Запрос падает, приложение показывает что угодно, кроме ошибки, а в логах — необработанное исключение. Выглядит как поломка библиотеки. На самом деле всё работает ровно так, как задумано, просто интуиция из синхронного кода здесь не применима.
Почему не ловит
launch — не вызов работы, а её запуск. Он создаёт корутину и немедленно возвращает управление. К моменту, когда исключение возникнет, блок try давно закончился: ловить уже нечего.
Дальше исключение идёт не вверх по стеку вызовов, а вверх по дереву корутин — к родительской корутине, оттуда к её родителю и так до корня скоупа. Это совсем другой маршрут, и try/catch, поставленный на пути вызова, его не пересекает.
Четыре рабочих способа
Первый — ловить внутри корутины. Самое простое и чаще всего достаточное:
scope.launch {
try {
repository.save(item)
} catch (e: IOException) {
showError()
}
}
У этого способа есть оговорка: родитель о проблеме не узнает. Иногда именно это и нужно — ошибка локальная, экран её показал, дальше жить можно. Но если поломка должна отменить всю группу задач, исключение надо перевыбросить после обработки.
Второй — через await() у async. Здесь поведение другое и, пожалуй, более ожидаемое. async не выбрасывает исключение сразу: оно сохраняется внутри Deferred и вылетает в момент, когда вы спрашиваете результат. Значит, и ловить его нужно вокруг await():
try {
val user = deferred.await()
} catch (e: IOException) {
showError()
}
Третий — обернуть не билдер, а coroutineScope. Вот здесь обычный try/catch работает ровно так, как вы ожидаете, потому что блок завершается как единое целое:
try {
coroutineScope {
launch { loadProfile() }
launch { loadFeed() }
}
} catch (e: Exception) {
showError() // поймает падение любой из двух
}
Если одна из корутин упала, вторая отменится, а исключение выйдет наружу — и будет поймано. Структурированная конкурентность при этом не нарушена.
Четвёртый — CoroutineExceptionHandler. Обработчик последнего рубежа, элемент контекста, добавляется через «+»:
val handler = CoroutineExceptionHandler { _, e -> log(e) }
val scope = CoroutineScope(Dispatchers.Default + handler)
Две вещи про него нужно знать заранее. Он срабатывает только для корневой корутины скоупа — если поставить его на дочерний launch, он будет проигнорирован, потому что исключение уйдёт родителю. И он не работает для async: там исключение живёт в Deferred и ждёт await().
Падение одной задачи роняет соседей
Это не побочный эффект, а правило: исключение в потомке отменяет родителя, а тот — всех остальных детей. Эффект домино по всему дереву.
Иногда это именно то, что нужно: если не загрузился профиль, грузить ленту бессмысленно. Но часто задачи независимы — три виджета на экране, и падение одного не повод гасить остальные. Тогда распространение нужно выключить.
Делается это заменой обычного Job на SupervisorJob. Либо в скоупе:
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
либо, короче, через корутин-скоуп функцию:
supervisorScope {
launch { loadWidgetA() } // упадёт — остальные продолжат
launch { loadWidgetB() }
launch { loadWidgetC() }
}
Тонкость, на которой спотыкаются: supervisorScope — это не то же самое, что CoroutineScope(SupervisorJob()). Первая — suspend-функция, которая создаёт скоуп неявно, дожидается содержимого и остаётся связанной с родителем: отменят родительскую корутину — отменится и она. Вторая — фабрика, которая просто отдаёт вам новый объект.
Что проверить, когда «исключение не ловится»
try/catchстоит снаружиlaunch— перенесите внутрь или обернитеcoroutineScope;- ловите вокруг
async, а не вокругawait(); CoroutineExceptionHandlerнавешен на дочернюю корутину, а не на корень скоупа;- ошибка одной задачи гасит остальные — нужен
supervisorScope.
Обработке исключений и распространению отмены по иерархии посвящена отдельная глава моей книги, а руководство по корутинам связывает эту тему с остальными.