try/catch вокруг launch не ловит исключение. И это не баг

Код, который пишут примерно все и примерно один раз:

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.

Обработке исключений и распространению отмены по иерархии посвящена отдельная глава моей книги, а руководство по корутинам связывает эту тему с остальными.