Перевод статьи Are You Handling Exceptions in Kotlin Coroutines Properly?

coding image

Как Kotlin разработчик, вы скорее всего знаете, что корутины в случае ошибки, выкидывают исключения.

Возможно вы думаете обработка таких исключений происходит как обычно в Kotlin/Java коде. К сожалению, при использовании вложенных корутин, все может работать не так как ожидается.

В этой статье я попробую показать ситуации, в которых требуется осторожность и расскажу про лучшие практики в обработке ошибок.

Работа вложенных корутин

Давайте начнем с примера, где все вроде бы выглядит нормально. Пример показывает сценарий, когда нужно обновить View, данные для которого комбинируются из двух разных источников и один из источников падает с ошибкой. Функция-билдер для корутин async будет использоваться в слое Repository для выполнения двух запросов паралелльно. Билдер требует CoroutineScope, обычно он берется из ViewModel в которой запускается выполнение корутины.

Метод в Repository будет выглядеть так:

suspend fun getNecessaryData(scope: CoroutineScope): List<DisplayModel> {
    val failingDataDeferred = scope.async { apiService.getFailingData() }
    val successDataDeferred = scope.async { apiService.getData() }
    return failingDataDeferred.await().plus(successDataDeferred.await())
        .map(DisplayModel::fromResponse)
}

Запрос с ошибкой просто пробрасывает исключение в своем теле через небольшой таймаут:

suspend fun getFailingData(): List<ResponseModel> {
    delay(100)
    throw RuntimeException("Request Failed")
}

Во ViewModel данные запрашиваются:

viewModelScope.launch {
    kotlin.runCatching { repository.getNecessaryData(this) }
        .onSuccess { liveData.postValue(ViewState.Success(it)) }
        .onFailure { liveData.postValue(ViewState.Error(it)) }
}

Я использую удобный kotlin.Result функционал здесь, runCatching оборачивает try-catch блок, a ViewState – класс-обертка для состояний UI. При запуске этого кода, приложение падает с нашим созданным RuntimeException. Это кажется странными, ведь мы используем try-catch блок для перехвата любых исключений. Чтобы понять, что здесь происходит, давайте повторим, как исключения обрабатываются в Kotlin и Java.

Повторный проброс исключений

Простой пример:

fun someMethod() {
   try {
      val failingData = failingMethod()
   } catch (e: Exception) {
      // handle exception
   }
}

fun failingMethod() {
   throw RuntimeException()
}

Исключение возникает в failingMethod. И в Kotlin и в Java, функции по умолчанию пробрасывают все исключения, которые не были обработаны внутри них. Благодаря этому механизму, исключения из функции failingMethod можно поймать в родительском коде через блок try-catch.

Распространение исключений

Давайте перенесем логику предыдущего примера во ViewModel:

viewModelScope.launch {
     try {
        val failingData = async { throw RuntimeException("Request Failed") }
        val data = async { apiService.getData() }
        val result = failingData.await().plus(data.await()).map(DisplayModel::fromResponse)
        liveData.postValue(ViewState.Success(result))
     } catch (e: Exception) {
        liveData.postValue(ViewState.Error(e))
     }
}

Можно заметить некоторое сходство. Первая функция async выглядит как failingMethod выше, но так как исключение не перехватывается похоже, что этот блок не пробрасывает исключение дальше!

Это первый ключевой момент в этой истории:

И launch и async функции не пробрасывают исключения, которые возникают внутри. Вместо этого, они РАСПРОСТРАНЯЮТ их вверх по иерархии корутины. Поведение верхнего уровня async объясняется ниже.

Иерархия корутины и CoroutineExceptionHandler

Наша иерархия корутин выглядит таким образом:

Coroutine image

На самом верху у нас находится область видимости (scope) от ViewModel, где мы создаем корутину верхнего уровня при помощи функции launch. Внутри этой корутины мы добавляем 2 дочерние корутины через async. Когда исключение возникает в любой дочерней корутине, исключение не пробрасывается, а немедленно передается вверх по иерархии пока не достигнет объекта области видимости (scope).

Далее scope передает исключение в обработчик CoroutineExceptionHandler. Он может быть установлен или в самом scope через конструктор или в корутине верхнего уровня, как параметр в функциях async и launch.

Имейте в виду, установка обработчика в любой дочерней корутине не будет работать.

Такой механизм распространиения исключений это часть Структурной Конкурентности (Structured Concurrency), принцип проектирования корутин, введеный авторами для правильного выполнения и отмены иерархии корутин без утечек памяти.

Больше деталей здесь.

Ок, но почему наше приложение падает, когда есть такой механизм? Потому что мы не установили никакого обработчика CoroutineExceptionHandler!

Давайте передадим обработчик в функцию launch (сделать это через scope здесь невозможно, так как viewModelScope создается не нами):

private val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
    liveData.postValue(ViewState.Error(throwable))
}

viewModelScope.launch(exceptionHandler) {
    // content unchanged
}

Теперь мы будем правильно получать ошибку в наше view.

Дополнительные возможности

После наших правок, блок try-catch больше не нужен, исключения больше не будут перехватываться там. Мы можем удалить его и полностью полагаться на установленный обработчик ошибок. Однако, это может быть не самым лучшим решением, если нам нужен больший контроль над выполнением корутин, так как этот обработчик собирает все исключения и не дает механизмов повтора или альтернативного исполнения.

Вторая возможность – удалить обработчик исключений, оставить логику блока try-catch, но изменить слой репозитория данных (Repository) с использованием специальных билдеров корутин для вложенного исполнения: coroutineScope и supervisorScope. Таким образом у нас будет больше контроля над потоком исполнения и, к примеру, можно использовать метод recoverCatching из kotlin.Result если требуется операция восстановления после ошибки.

Давайте взглянем, что эти билдеры предлагают.

coroutineScope билдер

Этот билдер создает дочерний scope в иерархии корутины. Ключевые особенности:

  • наследует контекст из вызывающей корутины и поддерживает структурную конкурентность
  • не распространяет исключения из дочерних корутин, вместо этого пробрасывает (re-throw) исключения
  • отменяет все дочерние корутины, если хотя бы одна из них падает с ошибкой

Теперь больше не надо передавать viewModelScope в метод:

suspend fun getNecessaryData(): List<DisplayModel> = coroutineScope {
     val failingDataDeferred = async { apiService.getFailingData() }
     val successDataDeferred = async { apiService.getData() }
     
     failingDataDeferred.await().plus(successDataDeferred.await())
        .map(DisplayModel::fromResponse)
}

После этих правок, исключение из первой функции async попадет в try-catch блок во ViewModel, потому что исключение повторно пробрасывается из функции билдера.

supervisorScope билдер

Билдер создает новый scope с SupervisorJob.

Ключевые особенности:

  • если одна из дочерних корутин падает с исключением, другие корутины не отменяются
  • дочерние корутины становятся верхнеуровневыми (можно настроить CoroutineExceptionHandler)
  • наследует контекст из вызывающей корутины и поддерживает структурную конкурентность (также как coroutineScope)
  • не распространяет исключения из дочерних корутин, вместо этого пробрасывает (re-throw) исключения (также как coroutineScope)

Соответственно, если наш первый запрос падает, мы все еще можем получить данные из второго async запроса, так как он не будет отменен.

Эта особенность требует обработчика CoroutineExceptionHandler в верхнеуровневой корутине, иначе supervisorScope все равно упадет. Причина этого в механизме, который обсуждали выше - scope всегда проверяет установлен ли обработчик ошибок. Если обработчика нет - будет падение.

suspend fun getNecessaryData(): List<DisplayModel> = supervisorScope {
     val failingDataDeferred = async(exceptionHandler) { apiService.getFailingData() }
     val successDataDeferred = async(exceptionHandler) { apiService.getData() }
     
     failingDataDeferred.await().plus(successDataDeferred.await())
        .map(DisplayModel::fromResponse)
}

К сожалению, если запустить этот код, ViewModel все еще перехватывает исключение. Почему так?

Верхнеуровневый async

В соответствии со второй особенностью supervisorScope, обе корутины запускаемые через async становятся верхнеуровневыми, которые обрабатывают исключения по-другому, чем вложенные async:

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

Обычные исключения в supervisorScope

В документации есть следующее: “Сбой в scope (исключение выбрасывается в блоке или на этапе отмены) приводит к сбою всего scope со всеми дочерними корутинами”.

В нашем сценарии, исключение выбрасывается при вызове ailingDataDeffered.await(). Это происходит вне билдера async, таким образом это не распространяется в supervisorScope, а выбрасывается как обычное исключение. Весь supervisorScope немедленно падает и пробрасывает это исключение.

Чтобы избежать этой проблемы, мы можем использовать launch решение, которое будет правильно распространять исключение в supervisorScope и вторая кортуна будет оставаться живой.

suspend fun getNecessaryData(): List<DisplayModel> = supervisorScope {
   buildList {
        launch(exceptionHandler) { apiService.getFailingData() }.join()
        launch(exceptionHandler) { apiService.getData() }.join()
    }.map(DisplayModel::fromResponse)
}

Короткая заметка: вызов join() тут нужен, потому что он приостанавливает корутину в которой работает. Благодаря этому метод getNecessaryData не вернет управление пока обе Job не будут завершены. В противном случае метод вернет управление сразу без всяких данных.

CancellationException

В последней части статьи, я бы хотел рассказать об исключении CancellationException, которое используется в механизме Structured Concurrency как сигнал отмены в корутинах. Это исключение передается всем корутинам внутри scope при его отмене (к примеру, если пользователь ушел с экрана) или если другая корутина падает.

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

private suspend fun fetchData(action: suspend () -> T) =
    try {
       liveData.postValue(ViewState.Success(action()))
    } catch (e: Exception) {
       liveData.postValue(ViewState.Error(e))
    }

Это работает ок, если есть такой вызов для каждой корутины (Я также использовал подобный подход выше).

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

Например, мы вызываем для загрузки данных следующее:

viewModelScope.launch {
    fetchData { someApi.request1() }
    fetchData { someApi.request2() }
}

launch запускает задачи последовательно по своей природе, это значит, что второй вызов fetchData ждет, пока не выполнится первый вызов.

Если пользователь уходит с экрана до того момента, когда request1 заканчивается, viewModelScope будет отменен и первый вызов fetchData перехватит и обработает CancellationException.

После этого, корутина продолжит свою работу и запустит request2 как ни в чем не бывало, потому что мы скрыли CancellationException от нее.

Это лишняя трата ресурсов, которая может привести также к утечкам памяти или даже падению приложения.

Чтобы это предотвратить, мы можем улучшить метод fetchData, чтобы повторно пробрасывать CancellationException. Таким образом вся родительская корутина будет отменена правильно:

private suspend fun fetchData(action: suspend () -> T) =
    try {
        liveData.postValue(ViewState.Success(action()))
    } catch (e: Exception) {
        liveData.postValue(ViewState.Error(e))
        if (e is CancellationException) {
            throw e
        }
    }

Также помним об удобном операторе kotlin.Result:

private suspend fun runDataFetch(action: suspend () -> T) =
    kotlin.runCatching { action() }
        .onSuccess { liveData.postValue(ViewState.Success(it) }
        .onFailure {
            liveData.postValue(ViewState.Error(it))
            if (it is CancellationException) {
                throw it
            }
        }

Это безопасный проброс конкретного исключения - мы можем быть уверены, корутина обработает его правильно.