Exception handling is probably one of the hardest parts when learning Coroutines. In this blog post, I am going to describe the reasons for its complexity and give you some key points that will help you to build a good understanding of the subject. You will then be able to implement a successful exception handling infrastructure in your own applications.
Info: You can watch an even more comprehensive video about Exception Handling in Kotlin Coroutines here!
Exception handling in pure Kotlin
Exception handling in pure Kotlin code (without Coroutines) is pretty straight forward. We basically only use the try-catch
clause to handle exceptions:
try { // some code throw RuntimeException("RuntimeException in 'some code'") } catch (exception: Exception) { println("Handle $exception") } // Output: // Handle java.lang.RuntimeException: RuntimeException in 'some code'
If an exception is thrown inside a regular function, this exception is “re-trown” by the function. This means that we are able to use a try-catch
clause to handle the exception on the call site:
fun main() { try { functionThatThrows() } catch (exception: Exception) { println("Handle $exception") } } fun functionThatThrows() { // some code throw RuntimeException("RuntimeException in regular function") } // Output // Handle java.lang.RuntimeException: RuntimeException in regular function
try-catch
in Coroutines
Let’s now have a look at the usage of try-catch
with Kotlin Coroutines. Using it inside a Coroutine (which is started with launch
in the example below) works as expected, as the exception is caught:
fun main() { val topLevelScope = CoroutineScope(Job()) topLevelScope.launch { try { throw RuntimeException("RuntimeException in coroutine") } catch (exception: Exception) { println("Handle $exception") } } Thread.sleep(100) } // Output // Handle java.lang.RuntimeException: RuntimeException in coroutine
But when we launch
another Coroutine inside the try
block …
fun main() { val topLevelScope = CoroutineScope(Job()) topLevelScope.launch { try { launch { throw RuntimeException("RuntimeException in nested coroutine") } } catch (exception: Exception) { println("Handle $exception") } } Thread.sleep(100) } // Output // Exception in thread "main" java.lang.RuntimeException: RuntimeException in nested coroutine
… you can see in the output that the exception isn’t handled anymore and the app crashes. This is very unexpected and confusing. Based on our knowledge and experience with try-catch
, we expect that every exception within a try
block is caught and enters the catch
block. But why isn’t this the case here?
Well, a Coroutine that doesn’t catch an exception by itself with a try-catch
clause, “completes exceptionally” or in simpler terms, it “fails”. In the example above, the Coroutine started with the inner launch
doesn’t catch the RuntimeException
by itself and so it fails.
As we have seen in the beginning, an uncaught exception in a regular function is “re-thrown”. This is not the case for an uncaught exception in a Coroutine. Otherwise, we would be able to handle it from outside and the app in the example above wouldn’t crash.
So what happens with an uncaught exception in a Coroutine then? As you probably know, one of the most innovative features of Coroutines is Structured Concurrency. To make all the features of Structured Concurrency possible, the Job
object of a CoroutineScope
and the Job
objects of Coroutines and Child-Coroutines form a hierarchy of parent-child relationships. An uncaught exception, instead of being re-thrown, is “propagated up the job hierarchy”. This exception propagation leads to the failure of the parent Job
, which in turn leads to the cancellation of all the Job
s of its children.
The job hierarchy of the code example above looks like this:
The exception of the child coroutine is propagated up to the Job
of the top-level Coroutine (1) and then up to the Job
of topLevelScope
(2).
Propagated exceptions can be handled by installing a CoroutineExceptionHandler
. If none is installed, the uncaught exception handler of the thread is invoked, which, depending on the platform, will probably lead to a print out of the exception followed by the termination of the application.
In my opinion, the fact that we have two different mechanisms for handling exceptions – try-catch
and CoroutineExceptionHandler
s – is one of the main factors why exception handling with Coroutines is so complex.
💥 Key Point #1
If a Coroutine doesn’t handle exceptions by itself with a try-catch
clause, the exception isn’t re-thrown and can’t, therefore, be handled by an outer try-catch
clause. Instead, the exception is “propagated up the job hierarchy” and can be handled by an installed CoroutineExceptionHandler
. If none is installed, the uncaught exception handler of the thread is invoked.
Coroutine Exception Handler
Alright, so now we know that a try-catch
is uesless if we start a failing coroutine in the try
block. So let’s instead install a CoroutineExceptionHandler
! We can pass a context
to the launch
coroutine builder. Since the CoroutineExceptionHandler
is a ContextElement
, we can install one by passing it to launch
when we start our child coroutine:
fun main() { val coroutineExceptionHandler = CoroutineExceptionHandler { coroutineContext, exception -> println("Handle $exception in CoroutineExceptionHandler") } val topLevelScope = CoroutineScope(Job()) topLevelScope.launch { launch(coroutineExceptionHandler) { throw RuntimeException("RuntimeException in nested coroutine") } } Thread.sleep(100) } // Output // Exception in thread "DefaultDispatcher-worker-2" java.lang.RuntimeException: RuntimeException in nested coroutine
Nevertheless, our exception isn’t handled by our coroutineExceptionHandler
and so the app crashes! This is because installing a CoroutineExceptionHandler
on child coroutines doesn’t have any effect. We have to either install the handler in the scope or the top-level coroutine, so either like this:
// ... val topLevelScope = CoroutineScope(Job() + coroutineExceptionHandler) // ...
or like this:
// ... topLevelScope.launch(coroutineExceptionHandler) { // ...
Only then is the exception handler able to handle the exception:
// .. // Output: // Handle java.lang.RuntimeException: RuntimeException in nested coroutine in CoroutineExceptionHandler
💥 Key Point 2
In order for a CoroutineExceptionHandler
to have an effect, it must be installed either in the CoroutineScope
or in a top-level coroutine.
try-catch
VS CoroutineExceptionHandler
As you have seen before, we have two options for handling exceptions: wrapping code inside our coroutine with try-catch
or installing a CoroutineExceptionHandler
. When should we choose which option?
The official documentation of the CoroutineExceptionHandler provides some good answers:
“
CoroutineExceptionHandler
is a last-resort mechanism for global “catch all” behavior. You cannot recover from the exception in theCoroutineExceptionHandler
. The coroutine had already completed with the corresponding exception when the handler is called. Normally, the handler is used to log the exception, show some kind of error message, terminate, and/or restart the application.
If you need to handle exception in a specific part of the code, it is recommended to use
try/catch
around the corresponding code inside your coroutine. This way you can prevent completion of the coroutine with the exception (exception is now caught), retry the operation, and/or take other arbitrary actions:”
Another aspect I want to mention here is that by handling an exception directly in a Coroutine with try-catch
we are not making use of the cancellation-related features of Structured Concurrency. For instance, let’s imagine that we start two Coroutines in parallel. They both somehow depend on each other so the completion of one doesn’t make sense if the other one fails. If we now use try-catch
to handle exceptions in each coroutine, the exception would not be propagated to the parent and therefore the other coroutine wouldn’t get canceled. This would waste a lot of resources. In these kinds of situations, we should use a CoroutineExceptionHandler
.
💥 Key Point 3
Use try/catch
if you want to retry the operation or do other actions before the Coroutine completes. Keep in mind that by catching the exception directly in the Coroutine, it isn’t propagated up the job hierarchy and you aren’t making use of the cancellation functionality of Structured Concurrency. Use the CoroutineExceptionHandler
for logic that should happen after the coroutine already completed.
launch{}
vs async{}
Up until now, we only used launch
in our examples for starting new Coroutines. However, exception handling is quite different between Coroutines that are started with launch
and Corotines that are started with async
.
Let’s have a look at the following example:
fun main() { val topLevelScope = CoroutineScope(SupervisorJob()) topLevelScope.async { throw RuntimeException("RuntimeException in async coroutine") } Thread.sleep(100) } // No output
This example doesn’t produce any output. So what happens with the RuntimeException
here? Is it just being ignored? No. In Coroutines started with async
, uncaught exceptions are also immediately propagated up the job hierarchy. But in contrast to Coroutines started with launch
, the exceptions aren’t handled by an installed CoroutineExceptionHandler
and also aren’t passed to the thread’s uncaught exception handler.
The return type of a Coroutine started with launch
is Job
, which is simply a representation of the Coroutine without a return value. If we need some result from a Coroutine, we have to use async
, which returns a Deferred
, which is a special kind of Job
that additionally holds a result value. If the async
Coroutine fails, the exception is encapsulated in the Deferred
return type, and is re-thrown when we call the suspend function .await()
to retrieve the result value on it.
Therefore, we can surround the call to .await()
it with a try-catch
clause. Since .await()
is a suspend
function, we have to start a new Coroutine to be able to call it:
fun main() { val topLevelScope = CoroutineScope(SupervisorJob()) val deferredResult = topLevelScope.async { throw RuntimeException("RuntimeException in async coroutine") } topLevelScope.launch { try { deferredResult.await() } catch (exception: Exception) { println("Handle $exception in try/catch") } } Thread.sleep(100) } // Output: // Handle java.lang.RuntimeException: RuntimeException in async coroutine in try/catch
Attention: The exception is only encapsulated in the Deferred
, if the async
Coroutine is a top-level Coroutine. Otherwise, the exception is immediately propagated up the job hierarchy and handled by a CoroutineExceptionHandler
or passed to the thread’s uncaught exception handler even without calling .await()
on it, as the following example demonstrates:
fun main() { val coroutineExceptionHandler = CoroutineExceptionHandler { coroutineContext, exception -> println("Handle $exception in CoroutineExceptionHandler") } val topLevelScope = CoroutineScope(SupervisorJob() + coroutineExceptionHandler) topLevelScope.launch { async { throw RuntimeException("RuntimeException in async coroutine") } } Thread.sleep(100) } // Output // Handle java.lang.RuntimeException: RuntimeException in async coroutine in CoroutineExceptionHandler
💥 Key point 4
Uncaught exceptions in both launch
and async
Coroutines are immediately propagated up the job hierarchy. However, if the top-level Coroutine was started with launch
, the exception is handled by a CoroutineExceptionHandler
or passed to the thread’s uncaught exception handler. On the other hand, if the top-level Coroutine was started with async
, the exception is encapsulated in the Deferred
return type and re-thrown when .await()
is called on it.
Exception handling properties of coroutineScope{}
When we talked about try-catch
with Coroutines at the beginning of this article, I told you that a failing coroutine is propagating its exception up the job hierarchy instead of re-throwing it and therefore, an outer try-catch
is ineffective.
However, when we surround a failing coroutine with the coroutineScope{}
scoping function, something interesting happens:
fun main() { val topLevelScope = CoroutineScope(Job()) topLevelScope.launch { try { coroutineScope { launch { throw RuntimeException("RuntimeException in nested coroutine") } } } catch (exception: Exception) { println("Handle $exception in try/catch") } } Thread.sleep(100) } // Output // Handle java.lang.RuntimeException: RuntimeException in nested coroutine in try/catch
We are now able to handle the exception with a try-catch
clause. So the scoping function coroutineScope{}
re-throws exceptions of its failing children instead of propagating them up the job hierarchy.
coroutineScope{}
is mainly used in suspend
functions to achieve “parallel decomposition”. These suspend
functions will re-throw exceptions of their failed coroutines and so we can set up our exception handling logic accordingly.
💥 Key Point 5
The scoping function coroutineScope{}
re-throws exceptions of its failed child coroutines instead of propagating them up the job hierarchy, which allows us to handle exceptions of failed coroutines with try-catch
Exception Handling properties of supervisorScope{}
By using the scoping function supervisorScope{}
, we are installing a new, independent nested scope in our job hierarchy with a SupervisorJob
as its Job
.
So code like this ….
fun main() { val topLevelScope = CoroutineScope(Job()) topLevelScope.launch { val job1 = launch { println("starting Coroutine 1") } supervisorScope { val job2 = launch { println("starting Coroutine 2") } val job3 = launch { println("starting Coroutine 3") } } } Thread.sleep(100) }
… will create the following job hierarchy:
Now, what’s crucial here to understand regarding exception handling, is that the supervisorScope
is a new independent sub-scope that has to handle exceptions by itself. It doesn’t re-throw exceptions of a failed Coroutine like coroutineScope
does, and it also doesn’t propagate exceptions to its parent – the Job
of topLevelScope
.
Another crucial thing to understand is exceptions are only propagated upwards until they either reach the top-level scope, or a SupervisorJob
. This means, that Coroutine 2 and Coroutine 3 are now top-level coroutines.
This also means we can now install a CoroutineExceptionHandler
in them that is actually called:
fun main() { val coroutineExceptionHandler = CoroutineExceptionHandler { coroutineContext, exception -> println("Handle $exception in CoroutineExceptionHandler") } val topLevelScope = CoroutineScope(Job()) topLevelScope.launch { val job1 = launch { println("starting Coroutine 1") } supervisorScope { val job2 = launch(coroutineExceptionHandler) { println("starting Coroutine 2") throw RuntimeException("Exception in Coroutine 2") } val job3 = launch { println("starting Coroutine 3") } } } Thread.sleep(100) } // Output // starting Coroutine 1 // starting Coroutine 2 // Handle java.lang.RuntimeException: Exception in Coroutine 2 in CoroutineExceptionHandler // starting Coroutine 3
The fact that coroutines that are started directly in supervisorScope
are top-level Coroutines also means that async
Coroutines now encapsulate their exceptions in their Deferred
objects …
// ... other code is identical to example above supervisorScope { val job2 = async { println("starting Coroutine 2") throw RuntimeException("Exception in Coroutine 2") } // ... // Output: // starting Coroutine 1 // starting Coroutine 2 // starting Coroutine 3
… and will only be re-thrown when calling .await()
💥 Key Point 6
The scoping function supervisorScope{}
installs a new independent sub-scope in the job hierarchy with a SupervisorJob
as the scope’s job. This new scope does not propagate its exceptions “up the job hierarchy” so it has to handle its exceptions on its own. Coroutines that are started directly from the supervisorScope
are top-level coroutines. Top-level coroutines behave differently than child coroutines when they are started with launch()
or async()
and furthermore it is possible to install CoroutineExceptionHandlers
in them.
That’s it!
Alright! These are the key points that I myself identified when spending a lot of time trying to understand exception handling with Coroutines.
If you liked this article, you probably also like my in-depth course about Kotlin Coroutines and Flow on Android: