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.
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
andlaunch
) 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.
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.
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.
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
.
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
.
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).
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.
supervisorScope
does not support changing context. If you need to both change context and use a SupervisorJob
, you need to wrap supervisorScope
with withContext
.
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.
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
.
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).
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.
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 regularJob
.supervisorScope
is a coroutine scope function that uses aSupervisorJob
instead of a regularJob
. It ignores exceptions from its children.withContext(SupervisorJob())
is not a replacement forsupervisorScope
. It does not work, becauseSupervisorJob
is not inherited, andwithContext
always uses a regularJob
.- When calling
await
onDeferred
, 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.