Урок по треку Kotlin 7 мин

Исключения в корутинах за 7 минут

Распространение ошибок, SupervisorJob, CoroutineExceptionHandler, CancellationException.

Обработка ошибок в корутинах это любимая тема 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.

Практические правила

  1. Ошибки ожидаемые (сеть, валидация) ловить в репозитории или use case и возвращать как результат (sealed-класс или Result), а не позволять им всплывать.
  2. Корневые scope создавать с SupervisorJob и CoroutineExceptionHandler для логирования.
  3. Параллельные независимые запросы: supervisorScope + async + try вокруг каждого await.
  4. Параллельные зависимые запросы (нужны все): coroutineScope, пусть одна ошибка отменит остальные.

Частые ошибки

  • try/catch вокруг launch.
  • Надеяться на CoroutineExceptionHandler в дочерней корутине.
  • launch(SupervisorJob()) вместо supervisorScope.
  • Глотать CancellationException через runCatching.
  • Ловить исключение от дочернего async только у await и удивляться отмене scope.

Что спросят на собесе

  • Как исключение распространяется по иерархии корутин?
  • Чем SupervisorJob отличается от Job?
  • Где работает CoroutineExceptionHandler, а где нет?
  • Разница обработки ошибок в launch и async.
  • Почему CancellationException нельзя глотать?

Закрепите за 5 минут

После урока в тренажёре 3-5 проверочных вопросов, дальше модуль уходит в повторения.