Are You Handling Exceptions in Kotlin Coroutines Properly?

Photo of Filip Grześkowiak

Filip Grześkowiak

Updated Mar 10, 2022 • 12 min read
Back view of modern programmer sitting and writing code in dark room-2

If you are a Kotlin developer, you most probably know that coroutines communicate errors in execution by throwing exceptions.

You might also think that handling these exceptions is as easy as in normal Kotlin or Java code. Unfortunately, when we start using nested execution, things might start working not quite as we would expect.

In this article I will try to show situations where you need to be more cautious about exceptions and show some best practices.

Nesting coroutines execution

Let’s start with an example, which may not seem broken at the first glance.

It will simulate a scenario in which the view needs to build a list from two individual endpoints, but one of them is failing. The async coroutine builder will be used in the Repository layer to perform these requests in parallel. This builder needs a CoroutineScope to work on and a common solution is to pass the scope from the ViewModel, in which the execution is started. A simple method may look as follows:

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

The failing method just throws an exception inside its body after a short time to simulate a failed response:

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

In the ViewModel we call for data:

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

I am using kotlin.Result operators here to hide a regular try-catch block within a more functional API and a ViewState wrapper class to represent different states of the view.

When we run this code, the app crashes with the exact RuntimeException we created. It might seem strange, as we thought we were prepared for any exceptions by calling the method within the try-catch block.

To best explain best what happened here, let's revise the basics of exception handling in Kotlin and Java.

Re-throwing exceptions

Let’s look at this short snippet:

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

fun failingMethod() {
   throw RuntimeException()
}

Here, the exception originates in a separate function. In both Kotlin and Java, functions by default re-throw all the exceptions that were not caught inside them. Thanks to this mechanism, the exception from the failingMethod can be caught in the parent try-catch block.

Propagating exceptions

With this in mind, let’s alter the previous example a little, so the whole logic resides only in 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))
     }
}

We can notice some similarities. The first async builder looks like the failingMethod from above, but since the exception is not caught, this coroutine builder is apparently not re-throwing it!

It's the first key point from this story:

Both launch and nested async builders do not re-throw exceptions that occur inside them. Instead, they PROPAGATE them up the coroutine hierarchy.

Behaviour of top-level async is described later.

Coroutine hierarchy and CoroutineExceptionHandler

Our current Coroutine hierarchy looks as follows:

Coroutine hierarchy

At the very top we have the ViewModel scope, in which we create a top-level coroutine using the launch builder. In this coroutine we create 2 child coroutines using async.
When the exception occurs in any of them, instead of being re-thrown, it is immediately propagated up the hierarchy until it reaches the scope.

The scope then passes the exception to the CoroutineExceptionHandler.
This object can be installed either in the scope itself by passing it in its constructor or in the top-level coroutine by passing it as a parameter to the launch or async methods.

Keep in mind, that installing it in any of the child coroutines won't work.

This mechanism of propagating exceptions is a part of Structured Concurrency, a design principle that the authors of coroutines introduced to ensure proper execution and cancelling of the coroutine hierarchy to avoid memory leaks.

You can read more about it here.
But why has our app crashed if such a mechanism exists? Because we did not install any CoroutineExceptionHandler at all!

We can fix it by passing the handler to the launch method (we cannot install it in the scope, because viewModelScope is not created by us):

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

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

Now we will properly receive an error state in our view.

Further possibilities

After this change, the try-catch block is now useless, as exceptions will always omit it.

We can remove it and rely only on the installed exception handler.
However, this might not be a perfect solution if we want to have more control over execution, as this handler gathers all exceptions from the whole hierarchy and doesn’t provide any particular retry or fallback mechanisms.

The second possibility is to remove the exception handler, leave the try-catch logic, but change the Repository layer to use specialised builders for nested execution: coroutineScope and supervisorScope. This way we’ll have more control over the flow and we can for instance utilise the recoverCatching method from kotlin.Result if some error recovery is needed.

Let’s look at what these builders offer.

coroutineScope builder

This builder creates a sub-scope in the coroutine hierarchy. Its key features are:

  • It inherits the caller’s Coroutine Context and supports Structured Concurrency
  • It doesn’t propagate exceptions from its children but re-throws them instead
  • It cancels all other children if one of them fails

Now we don’t need to pass the viewModelScope to the method anymore:

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 applying this change, the exception from the fist async ends up in ViewModel's catch block, because this time it is re-thrown from the builder.

supervisorScope builder

This builder creates a new scope with a SupervisorJob attached. It shares the first two features of the coroutineScope builder, but introduces two extra:

  • If one of the coroutines inside fails, the others are not cancelled
  • Coroutines created inside become top-level Coroutines, (we can install a CoroutineExceptionHandler in them)

According to its first feature, if the first request fails, we should still be able to get data from the second request, as the second async won't be cancelled.

This feature requires an installed CoroutineExceptionHandler in its top-level coroutines, otherwise the supervisorScope will fail anyway.
That's because of the mechanism mentioned earlier in the story - a scope always looks for an installed exception handler. If it can't find any, it fails.

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, when we run this code, ViewModel still catches the exception.

Why is that?

Top-level async

According to the second feature of supervisorScope, both coroutines launched by the async became top-level coroutines and a top-level async treats exceptions differently than a nested async:

Top-level async encapsulates an exception inside the Deferred object returned by the builder. It is thrown as a normal exception only when invoking the await() method.

Normal exceptions in supervisorScope

Additionally, we can read the following in supervisorScope's documentation:

"A failure of the scope itself (exception thrown in the [block] or cancellation) fails the scope with all its children."

In our scenario, the exception is thrown when we call failingDataDeffered.await(). It happens outside of the async builder, so it isn't propagated to supervisorScope, but is thrown as a normal exception. The whole supervisorScope immediately fails and re-throws the exception.

To avoid this issue, we can use a launch implementation, which will properly propagate the exception to the supervisorScope and the second coroutine will be kept alive:

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

A quick note here: join() is necessary, because it suspends the coroutine it works in. Thanks to it, the getNecessaryData method won't return until both Jobs are completed. Otherwise, this method would return immediately without any data.

CancellationException

As the last part of this story I’d like to describe dealing with CancellationException, which is used by the Structured Concurrency mechanism to signal cancellation of coroutines. This exception is passed to all coroutines inside the scope if it gets cancelled (for example when a user leaves a screen), or when another coroutine fails.

Very often we unintentionally break this mechanism, by using such methods for wrapping coroutines execution:

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

There’s nothing wrong with executing suspending methods like this as long as there’s only one such invocation per coroutine (I also used a similar approach earlier in the story).

However, if there are more of them in the same coroutine, we are asking for trouble, because we consume the CancellationException ourselves and prevent the coroutine from being cancelled properly!

Imagine we call for data as follows:

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

Due to the nature of the launch coroutine, execution here is synchronous, meaning that the second fetchData waits for the completion of the first one.

If a user leaves the screen before request1 completes, viewModelScope will be cancelled and the first fetchData method will catch and consume the CancellationException.
After that, the coroutine will keep running and will start executing request2 anyway, because we’ve hidden the CancellationException from it!

This is a real waste of device resources and can lead to memory leaks or even crashes.

To prevent it from happening, we can simply improve the fetchData method to re-throw the CancellationException. This way the whole coroutine will be cancelled properly:

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 it when using kotlin.Result operators:

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

Throwing this particular exception is safe - we can be confident that it will be properly handled by the coroutine.

Photo of Filip Grześkowiak

More posts by this author

Filip Grześkowiak

Lost with AI?  Get the most important news weekly, straight to your inbox, curated by our CEO  Subscribe to AI'm Informed

Read more on our Blog

Check out the knowledge base collected and distilled by experienced professionals.

We're Netguru

At Netguru we specialize in designing, building, shipping and scaling beautiful, usable products with blazing-fast efficiency.

Let's talk business