article banner

Exception handling in Kotlin Coroutines

This is a chapter from the book Kotlin Coroutines. You can find it on LeanPub or Amazon.

Exception handling mechanism is often misunderstood by developers. It is really powerful, as it often helps us effortlessly clean up resources, but it must be understood well, or otherwise it can lead to unexpected behavior. In this chapter, we will discuss how exceptions are handled in coroutines, and how to stop them from cancelling our coroutines.

Exceptions and structured concurrency

Let's start this section with a simple example. Consider fetchUserDetails that concurrently fetches user data and user preferences, and then combines them into a single object. This is of course a simplification of an extremely popular use case, where a suspending function starts multiple asynchronous operations, and then awaits for their results.

suspend fun fetchUserDetails(): UserDetails = coroutineScope { val userData = async { fetchUserData() } val userPreferences = async { fetchUserPreferences() } UserDetails(userData.await(), userPreferences.await()) }

Now consider a situation, in which fetchUserPreferences throws an exception. What should happen to fetchUserDetails call? What about the coroutine that calls fetchUserData? I believe that the answer given to us by the kotlinx.coroutines library is the best one. fetchUserDetails should throw the exception that occurred in fetchUserPreferences, and the coroutine that calls fetchUserData should be cancelled. This is the default behavior of the kotlinx.coroutines library. How does it work?

The general rule of exception handling in Kotlin Coroutines is that:

  • Suspending functions and synchronous coroutines, including coroutine scope functions and runBlocking, throw exceptions that ended their body.
  • Asynchronous coroutine builders (async and launch) propagate exceptions that ended their body to their parents via scope. An exception received this way is treated as if it occurred in the parent coroutine.

In this case, an exception occurs in the fetchUserPreferences coroutine, so inside async. It ends this coroutine builder body, so propagates to the parent of async, which is coroutineScope. That means coroutineScope treats this exception like it occurred in its body, so it throws it, so this exception is thrown from fetchUserDetails. coroutineScope also gets cancelled, so all its children are cancelled as well. This is why the fetchUserData coroutine is cancelled.

An important consequence of this mechanism is that exceptions propagate from child to parent. Yes, if you have a process that starts a number of coroutines, and one of them throws an exception, that will automatically lead to the cancellation of all the other coroutines. Let's look at the example below. Once a coroutine receives an exception, it cancels itself and propagates the exception to its parent (launch). The parent cancels itself and all its children, then it propagates the exception to its parent (runBlocking). runBlocking is a root coroutine (it has no parent), so it just ends the program by rethrowing this exception. This way, everything is cancelled, and program execution stops.

import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking //sampleStart fun main(): Unit = runBlocking { launch { launch { delay(1000) throw Error("Some error") } launch { delay(2000) println("Will not be printed") } launch { delay(500) // faster than the exception println("Will be printed") } } launch { delay(2000) println("Will not be printed") } } // Will be printed // Exception in thread "main" java.lang.Error: Some error... //sampleEnd

So how can we stop exception propagation? One obvious way is catching exceptions from suspending functions. We can catch them before they reach coroutine builders, or catch exceptions from scope functions.

// Exception in fetchUserPreferences is ignored suspend fun fetchUserDetails(): UserDetails = coroutineScope { val userData = async { fetchUserData() } val userPreferences = async { try { fetchUserPreferences() } catch (e: Throwable) { println("Error in fetchUserPreferences: $e") null } } UserDetails(userData.await(), userPreferences.await()) }
// Exception in fetchUserPreferences cancells fetchUserDetails, // and makes fetchUserData return null suspend fun fetchUserDetails(): UserDetails? = try { coroutineScope { val userData = async { fetchUserData() } val userPreferences = async { fetchUserPreferences() } UserDetails(userData.await(), userPreferences.await()) } } catch (e: Throwable) { println("Error in fetchUserDetails: $e") null }

However, we cannot catch exceptions from coroutine builders, as those exceptions are propagated using scope. The only situation when an exception is not propagated via scope from child to parent is when this parent uses a SupervisorJob instead of a regular Job. That typically applies to supervisorScope coroutine scope function, and to custom CoroutineScope with SupervisorJob as a context. So let's talk about SupervisorJob.

SupervisorJob

SupervisorJob is a special kind of job that ignores all exceptions in its children. SupervisorJob is generally used as part of a scope in which we start multiple coroutines (more about this in the Constructing coroutine scope chapter). Thanks to that, an exception in one coroutine will not cancel this scope and all its children.

import kotlinx.coroutines.* //sampleStart fun main(): Unit = runBlocking { val scope = CoroutineScope(SupervisorJob()) scope.launch { delay(1000) throw Error("Some error") } scope.launch { delay(2000) println("Will be printed") } delay(3000) println(scope.isActive) } // (1 sec) // Exception... // (2 sec) // Will be printed // true //sampleEnd

In the above example, an exception occurs in the first coroutine, but it does not cancel the second coroutine, and the scope remains active. If we used a regular Job instead of SupervisorJob, the exception would propagate to the parent, the second coroutine would be cancelled, and scope would end in "Cancelled" state.

Do not use SupervisorJob as a builder argument

A common mistake is to use a SupervisorJob as an argument to a parent coroutine, like in the code below. It won't help us handle exceptions. Remember that Job is the only context that is not inherited, so when it is passed to a coroutine builder, it becomes a parent. So in this case SupervisorJob has only one direct child, namely the launch defined at 1 that received this SupervisorJob as an argument. So, when an exception occurs in this child, it propagates to the parent, which uses a regular Job, so it cancels all its children. There is no advantage of using SupervisorJob over Job.

import kotlinx.coroutines.* //sampleStart fun main(): Unit = runBlocking { // DON'T DO THAT! launch(SupervisorJob()) { // 1 launch { delay(1000) throw Error("Some error") } launch { delay(2000) println("Will not be printed") } } delay(3000) } // Exception... //sampleEnd

The same is true for many other cases. In the below code, exception in launch 1 propagates to runBlocking, that internally uses a regular Job, so it cancels launch 2. Then runBlocking throws this exception. Using SupervisorJob in this case changes absolutely nothing, because it becomes a parent of runBlocking, it does not change the fact that runBlocking uses a regular Job.

import kotlinx.coroutines.* //sampleStart // DON'T DO THAT! fun main(): Unit = runBlocking(SupervisorJob()) { launch { // 1 delay(1000) throw Error("Some error") } launch { // 2 delay(2000) println("Will not be printed") } } // Exception... //sampleEnd

supervisorScope

The only simple way to start a coroutine with a SupervisorJob is to use supervisorScope. It is a coroutine scope function, so it behaves just like coroutineScope, but it uses a SupervisorJob instead of a regular Job. This way, exceptions from its children are ignored (they only print stacktrace).

import kotlinx.coroutines.* //sampleStart fun main(): Unit = runBlocking { supervisorScope { launch { delay(1000) throw Error("Some error") } launch { delay(2000) println("Will be printed") } launch { delay(2000) println("Will be printed") } } println("Done") } // (1 sec) // Exception... // Will be printed // Will be printed // Done //sampleEnd

In the above example, an exception occurs in the first coroutine, but it does not cancel the other coroutines. They are executed, and the program ends with "Done". If we used coroutineScope instead of supervisorScope, the exception would propagate to runBlocking, and the program would end with an exception without printing anything.

Beware, that supervisorScope ignores exceptions only from its children. If an exception occurs in supervisorScope itself, it breaks this coroutine builder, and it propagates to its parent. If an exception occurs in a child of a child, it propagates to the parent of this child, destroys it, and only then gets ignored.

supervisorScope is often used when we need to start multiple independent processes, and we do not want an exception in one of them to cancel the others.

suspend fun notifyAnalytics(actions: List<UserAction>) = supervisorScope { actions.forEach { action -> launch { notifyAnalytics(action) } } }

supervisorScope does not support changing context. If you need to both change context and use a SupervisorJob, you need to wrap supervisorScope with withContext.

suspend fun notifyAnalytics(actions: List<UserAction>) = withContext(dispatcher) { supervisorScope { actions.forEach { action -> launch { notifyAnalytics(action) } } } }

Do not use withContext(SupervisorJob())

Beware, that coroutineScope cannot be replaced with withContext(SupervisorJob())! It is because Job cannot be set from the outside, and withContext always uses a regular Job.

import kotlinx.coroutines.* //sampleStart fun main(): Unit = runBlocking { // DON'T DO THAT! withContext(SupervisorJob()) { launch { delay(1000) throw Error("Some error") } launch { delay(2000) println("Will be printed") } launch { delay(2000) println("Will be printed") } } delay(1000) println("Done") } // (1 sec) // Exception... //sampleEnd

The problem here is that Job is the only context that is not inherited. Each coroutine needs its own job, and passing a job to a coroutine makes it a parent. So here SupervisorJob is a parent of withContext coroutine. When a child has an exception, it propagates to coroutine coroutine, cancels its Job, cancels children, and throws an exception. The fact that SupervisorJob is a parent changes nothing.

Exceptions and await call

When we call await on Deferred, it should return the value if the coroutine finished successfully, or throw an exception if the coroutine ended with an exception (it is CancellationException if the coroutine was cancelled). That is why if we want to silence exceptions from async, it is not enough to use supervisorScope. We also need to catch the exception when calling await.

import kotlinx.coroutines.* //sampleStart class MyException : Throwable() suspend fun main() = supervisorScope { val str1 = async<String> { delay(1000) throw MyException() } val str2 = async { delay(2000) "Text2" } try { println(str1.await()) } catch (e: MyException) { println(e) } println(str2.await()) } // MyException // Text2 //sampleEnd

Coroutine exception handler

When dealing with exceptions, sometimes it is useful to define default behavior for all exceptions. This is where the CoroutineExceptionHandler context comes in handy. It does not stop the exception propagating, but it can be used to define what should happen in the case of an exception (the default behavior is that exception stacktrace is printed).

import kotlinx.coroutines.* //sampleStart fun main(): Unit = runBlocking { val handler = CoroutineExceptionHandler { ctx, exception -> println("Caught $exception") } val scope = CoroutineScope(SupervisorJob() + handler) scope.launch { delay(1000) throw Error("Some error") } scope.launch { delay(2000) println("Will be printed") } delay(3000) } // Caught java.lang.Error: Some error // Will be printed //sampleEnd

This context is useful on many platforms to add a default way of dealing with exceptions. For Android, it is often used to provide a default way of handling exceptions in the UI layer. It can also be used to specify a default way of logging exceptions.

val handler = CoroutineExceptionHandler { _, exception -> Log.e("CoroutineExceptionHandler", "Caught $exception") }

Summary

In this chapter you leaned that:

  • Exceptions propagate from child to parent. Suspending functions (including coroutine scope functions) throw exceptions, and asynchronous coroutine builders propagate exceptions to their parents via scope.
  • To stop exception propagation, you can catch exceptions from suspending functions before they reach coroutine builders, or catch exceptions from scope functions.
  • SupervisorJob is a special kind of job that ignores all exceptions in its children. It is used to prevent exceptions from cancelling all children of a scope.
  • SupervisorJob should not be used as a builder argument, as it does not change the fact that the parent uses a regular Job.
  • supervisorScope is a coroutine scope function that uses a SupervisorJob instead of a regular Job. It ignores exceptions from its children.
  • withContext(SupervisorJob()) is not a replacement for supervisorScope. It does not work, because SupervisorJob is not inherited, and withContext always uses a regular Job.
  • When calling await on Deferred, it should return the value if the coroutine finished successfully, or throw an exception if the coroutine ended with an exception.
  • CoroutineExceptionHandler is a context that can be used to define default behavior for all exceptions in a coroutine.