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.
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
andlaunch
) 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.
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.
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.
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
.
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
.
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
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.
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())
You cannot replace supervisorScope
with withContext(SupervisorJob())
! This 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, 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
.
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).
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.
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 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 good replacement forsupervisorScope
becauseSupervisorJob
is not inherited, andwithContext
always uses a regularJob
.- When calling
await
onDeferred
, 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.