» » Kotlin, handle exceptions in coroutines correctly

Kotlin, handle exceptions in coroutines correctly

As a Kotlin developer, you probably know that coroutines throw exceptions when they fail.

Perhaps you think that such exceptions are handled as usual in Kotlin/Java code. Unfortunately, when using nested coroutines, things may not work as expected.

In this article, I will try to show situations in which caution is required and talk about best practices in error handling.

How nested coroutines work

Let's start with an example where everything seems to be normal. The example shows a scenario when you need to update a View whose data is combined from two different sources and one of the sources crashes with an error. The coroutine builder function  will be used in the Repositoryasync  layer   to execute two queries in parallel. The builder requires  , usually it is taken from the  ViewModel  in which the execution of the coroutine starts.CoroutineScope

The method in the  Repository  will look like this:

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)
}

A request with an error simply throws an exception in its body after a short timeout:

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

In  the ViewModel  , the data is requested:

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

I'm using a handy  kotlin.Result feature here,  runCatching wraps  try-catch a block, a  ViewState is a wrapper class for UI states. When this code is run, the application  crashes  with our generated  RuntimeException. This seems strange since we are using  try-catch a block to catch any exceptions. To understand what's going on here, let's recap how exceptions are handled in Kotlin and Java.

Rethrow Exceptions

Simple example:

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

fun failingMethod() {
   throw RuntimeException()
}

The exception occurs in  failingMethod. In both Kotlin and Java, functions by default  throw  all exceptions that were not handled within them. Thanks to this mechanism, exceptions from a function  failingMethod can be caught in the parent code through the  try-catch.

Exception Propagation

Let's move the logic of the previous example to the  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))
     }
}

You can see some similarities. The first function  async looks like the  failingMethodone above, but since the exception is not caught, it looks like this block doesn't rethrow the exception!

This is the first key moment in this story:

Both  functions launch do  async not throw exceptions that occur internally. Instead, they PROPAGATE them up the coroutine hierarchy. The top level behavior  async is explained below.

Coroutine Hierarchy and CoroutineExceptionHandler

Our coroutine hierarchy looks like this:

 

At the very top, we have the scope from the  ViewModel , where we create the  top-level coroutine  using the  launch. Inside this coroutine, we add 2  child  coroutines via  async. When an exception occurs in any child coroutine, the exception is not thrown, but is immediately propagated up the hierarchy until it reaches the scope object.

Next  , scope  passes the exception to the handler  CoroutineExceptionHandler. It can be set either in the  scope itself  via the constructor, or in the top-level coroutine, as a parameter in the  async and  functions launch.

Keep in mind, setting a handler in any  child  coroutine will not work.

This exception propagation mechanism is part of  Structured Concurrency , a coroutine design principle introduced by the authors to correctly execute and cancel the coroutine hierarchy without memory leaks.

More details here.

Ok, but why does our application crash when there is such a mechanism? Because we haven't installed any handler  CoroutineExceptionHandler!

Let's pass the handler to the function  launch (it's impossible to do this through scope here, since it's  viewModelScope not created by us):

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

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

Now we will correctly receive an error in our  view .

Additional features

After our edits, the block  try-catch is no longer needed, exceptions will no longer be caught there. We can remove it and rely entirely on the installed error handler. However, this may not be the best solution if we need more control over coroutine execution, as this handler catches all exceptions and does not provide retry or alternate execution mechanisms.

The second possibility is to remove the exception handler, leave the block logic  try-catch, but change the data repository layer ( Repository ) using special coroutine builders for nested execution:  coroutineScope and  supervisorScope. This way we will have more control over the execution flow and, for example, we can use the  recoverCatching from  method kotlin.Result if an error recovery operation is required.

Let's take a look at what these builders offer.

coroutineScope builder

This builder creates a child  scope  in the coroutine hierarchy. Key features:

  • inherits the context from the calling coroutine and supports structural concurrency;

  • does not propagate exceptions from child coroutines, instead re-throws exceptions;

  • cancels all child coroutines if at least one of them fails.

Now you no longer need to pass  viewModelScope to the method:

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

After these edits, the exception from the first function  async will end up in  try-catch the block in  ViewModelbecause the exception is rethrown from the builder function.

supervisorScope builder

The builder creates a new  scope  with  SupervisorJob.

Key features:

  • if one of the child coroutines crashes with an exception, the other coroutines are not cancelled;

  • child coroutines become top-level (can be configured  CoroutineExceptionHandler);

  • inherits the context from the calling coroutine and supports structural concurrency (same as  coroutineScope);

  • does not propagate exceptions from child coroutines, instead re-throws exceptions (just like  coroutineScope).

Accordingly, if our first request fails, we can still get data from the second  async request, since it will not be canceled.

This feature requires a handler  CoroutineExceptionHandler in the top-level coroutine, otherwise  supervisorScope it will still crash. The reason for this is in the mechanism discussed above -  scope  always checks to see if an error handler is set. If there is no handler, there will be a fall.

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)
}

Unfortunately, if you run this code,  ViewModel it still catches the exception. Why is that?

Top-level async

According to the second feature  supervisorScope, both coroutines run through  async become top-level ones that handle exceptions differently than nested ones  async:

async The top level hides the exception handling in the object  Defferedthat the builder returns. Object throws normal exception only when method is called await()

Common exceptions in supervisorScope

The documentation says: “A failure in  scope  (an exception is thrown in a block or during a cancel step) causes the entire  scope  and all child coroutines to fail.”

In our scenario, an exception is thrown when  ailingDataDeffered.await(). This happens outside of the builder  async, so it's not propagated to  supervisorScope, but thrown as a normal exception. Everything  supervisorScopeimmediately crashes and throws this exception.

To avoid this problem, we can use a  launch solution that will properly propagate the exception to  supervisorScope and keep the second cortuna alive.

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

A quick note: the call is  join() needed here because it suspends the coroutine in which it is running. Due to this, the method  getNecessaryData will not return until both  Job are completed. Otherwise, the method will return immediately without any data.

CancellationException

In the last part of the article, I would like to talk about the exception  CancellationException, which is used in the  Structured Concurrency mechanism  as a cancellation signal in coroutines. This exception is passed to all coroutines inside the  scope  when it is canceled (for example, if the user left the screen) or if another coroutine crashes.

Very often we inadvertently break this mechanism by using this approach to wrap coroutines:

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

It works ok if there is such a call for each coroutine (I also used a similar approach above).

However, if there are several coroutines inside another, we can get in trouble because we intercept  CancellationException ourselves and thereby break the internal mechanism for canceling coroutines.

For example, we call the following to load dаta:

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

launch runs tasks sequentially in nature, which means that the second call  fetchData waits until the first call is completed.

If the user leaves the screen before it  request1 ends,  viewModelScope it will be canceled and the first call will be  fetchData intercepted and processed by  CancellationException.

After that, the coroutine will continue its work and run  request2 as if nothing had happened, because we hid  CancellationException from it.

This is a waste of resources, which can also lead to memory leaks or even crashes of the application.

To prevent this, we can improve the method  fetchDatato re-forward  CancellationException. This way the entire parent coroutine will be canceled correctly:

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
        }
    }

Also remember about the convenience operator  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
            }
        }

This is a safe way to throw a specific exception - we can be sure that the coroutine will handle it correctly.

Related Articles

Add Your Comment

reload, if the code cannot be seen

All comments will be moderated before being published.