Обработка ошибок в корутинах это любимая тема senior-собесов: правила неочевидные, и интуиция из обычного кода подводит. Главное запомнить, как ошибка идёт по дереву Job и где её реально можно поймать.
Ошибка всплывает к родителю
В обычном Job исключение дочерней корутины (кроме CancellationException) делает три вещи: отменяет родителя, родитель отменяет остальных детей, а затем ошибка уходит выше, до корня.
scope.launch { // родитель
launch { error("boom") } // упал
launch { delay(1000); println("никогда") } // будет отменён
}
try/catch вокруг launch не работает
try {
scope.launch { error("boom") }
} catch (e: Exception) {
// сюда не попадём: launch вернулся сразу, ошибка случится позже в другой корутине
}
Ловить нужно внутри корутины, вокруг конкретного suspend-вызова:
scope.launch {
try { api.load() } catch (e: IOException) { showError() }
}
launch и async ведут себя по-разному
launch: необработанное исключение сразу распространяется к родителю. В корневой корутине оно уходит вCoroutineExceptionHandler, а если его нет, в обработчик потока (на Android это падение приложения).async: исключение сохраняется вDeferredи бросается приawait(). Но еслиasyncдочерний (не корневой), он всё равно сразу отменит родителя, даже если вы обернулиawaitвtry.
coroutineScope {
val d = async { error("boom") }
try { d.await() } catch (e: Exception) { } // поймали, но scope всё равно отменён и бросит boom
}
SupervisorJob и supervisorScope
SupervisorJob меняет правило: ошибка ребёнка не отменяет родителя и братьев. Каждый ребёнок сам отвечает за свою ошибку.
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
supervisorScope {
launch { error("boom") } // упал только он
launch { delay(100); println("жив") }
}
viewModelScope и lifecycleScope используют SupervisorJob: падение одного запроса не отменяет остальные запросы экрана.
Ловушка: launch(SupervisorJob()) { ... } внутри scope не делает детей «супервизорскими». Переданный Job становится родителем новой корутины и рвёт связь с внешним scope: structured concurrency ломается, а ошибки детей внутри по-прежнему отменяют эту корутину. Используйте supervisorScope { }.
CoroutineExceptionHandler
Последний рубеж для необработанных исключений, аналог Thread.UncaughtExceptionHandler:
val handler = CoroutineExceptionHandler { _, e -> log(e) }
val scope = CoroutineScope(SupervisorJob() + handler)
scope.launch { error("boom") } // попадёт в handler
Правила:
- работает только в корневой корутине или в прямых детях
SupervisorJob/supervisorScope; в обычном дочернемlaunchего игнорируют, ошибка уходит родителю; - не работает для
async: там ошибка ждётawait(); - не восстанавливает корутину, она уже завершилась; это место для логирования и показа ошибки.
CancellationException особенный
Отмена тоже исключение, но «нормальное»: оно не отменяет родителя и не попадает в обработчик. Поэтому:
- не глотайте его в
catch (e: Exception)и вrunCatching; - если поймали, пробросьте дальше или вызовите
ensureActive(); withTimeoutбросаетTimeoutCancellationException, это подклассCancellationException. Если не поймать его внутри, он отменит корутину без ошибки для родителя, что иногда удивляет.
Несколько ошибок сразу
Если упали несколько детей, родитель получит первое исключение, а остальные будут прикреплены к нему как suppressed.
Практические правила
- Ошибки ожидаемые (сеть, валидация) ловить в репозитории или use case и возвращать как результат (
sealed-класс илиResult), а не позволять им всплывать. - Корневые scope создавать с
SupervisorJobиCoroutineExceptionHandlerдля логирования. - Параллельные независимые запросы:
supervisorScope+async+tryвокруг каждогоawait. - Параллельные зависимые запросы (нужны все):
coroutineScope, пусть одна ошибка отменит остальные.
Частые ошибки
try/catchвокругlaunch.- Надеяться на
CoroutineExceptionHandlerв дочерней корутине. launch(SupervisorJob())вместоsupervisorScope.- Глотать
CancellationExceptionчерезrunCatching. - Ловить исключение от дочернего
asyncтолько уawaitи удивляться отмене scope.
Что спросят на собесе
- Как исключение распространяется по иерархии корутин?
- Чем
SupervisorJobотличается отJob? - Где работает
CoroutineExceptionHandler, а где нет? - Разница обработки ошибок в
launchиasync. - Почему
CancellationExceptionнельзя глотать?