article banner

Exception handling in Kotlin Coroutines

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

The 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, 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, which 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 waits 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 the 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, but 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 end their body.
  • Asynchronous coroutine builders (async and launch) propagate exceptions that end their body to their parents via the 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, i.e., inside async. It ends this coroutine builder body and therefore propagates to the parent of async, which is coroutineScope. This means that coroutineScope treats this exception like it occurred in its body, so it throws this exception; as a result, this exception is thrown from fetchUserDetails. coroutineScope also gets cancelled, so all its children are also cancelled. 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, this 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, but we can also catch them from scope functions, or before they reach coroutine builders.

// Exception in fetchUserPreferences is ignored suspend fun fetchUserDetails(): UserDetails = coroutineScope { val userData = async { fetchUserData() } val userPreferences = async { try { fetchUserPreferences() } catch (e: Exception) { 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: Exception) { println("Error in fetchUserDetails: $e") null }

However, we cannot catch exceptions from coroutine builders because these exceptions are propagated using scope. The only situation when an exception is not propagated via the scope from child to parent is when this parent uses a SupervisorJob instead of a regular Job. This typically applies to the 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 this, 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 the scope would end in the "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. This won't help us handle exceptions. Remember that Job is the only context that is not inherited, so it becomes a parent when it is passed to a coroutine builder. In this case, SupervisorJob has only one direct child, namely the launch defined at 1 that received this SupervisorJob as an argument. Therefore, when an exception occurs in this child, it propagates to the parent, which uses a regular Job, so this coroutine 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 code below, an exception in launch 1 propagates to runBlocking, which cancels launch 2 because it uses a regular Job internally. Then runBlocking throws this exception. Using SupervisorJob in this case changes absolutely nothing because it becomes a parent of runBlocking. Using SupervisorJob 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 only ignores exceptions from its children. If an exception occurs in supervisorScope itself, it breaks this coroutine builder and the exception 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 don't 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())

You cannot replace supervisorScope with withContext(SupervisorJob())! This 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, SupervisorJob here is a parent of the withContext coroutine. When a child has an exception, it propagates to the withContext coroutine, cancels its Job, cancels its 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 result of this coroutine if the coroutine finished successfully, or it should throw an exception if the coroutine ended with an exception (i.e., CancellationException if the coroutine was cancelled). This 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(): Unit = 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

CoroutineExceptionHandler

When dealing with exceptions, sometimes it is useful to define default behavior for all uncaught 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 uncaught 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

Typically CoroutineExceptionHandler handler will only be invoked for the root coroutines created using the launch builder, and coroutines started in supervisorScope. Regular children coroutines delegate handling of their exceptions to their parent coroutine, which also delegates to the parent, and so on until the root, so the CoroutineExceptionHandler installed in their context is never used. A coroutine that was created using async always catches all its exceptions and represents them in the resulting Deferred object, so it cannot result in uncaught exceptions. Coroutines running with SupervisorJob do not propagate exceptions to their parent and are treated like root coroutines.

CoroutineExceptionHandler context can be useful to add a default way of dealing with exceptions. On Android, it is often used to provide a default way of handling exceptions in the UI layer. In general, 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've learned 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 the 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 canceling all the 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 good replacement for supervisorScope 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 it should 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.