Exceptional Exceptions for Coroutines made easy…?
Our code in Kotlin’s Coroutines may throw exceptions and managing them is not always as straightforward as we may think. Let’s examine the rules and offer some tips on how to deal with them.
What is this blog post really about?
We started building our app, we chose Kotlin and we decided to use Coroutines as our main pattern to deal with asynchronous code. No regrets! And this article is not meant to convince us of this, or otherwise.
However… dealing with exceptions is a bit trickier than we expected. I myself tried to figure out some issues I had when dealing with exceptions thrown while launch
ing a Coroutine. Based on the responses I got, I figured out it’s not too bad, but there are some rules and gotcha’s.
This blog post aims to help out with this by putting it all in one spot and by showing a few small code examples. I assume we already know about the basics of Coroutines, what a suspend
function is and what the purpose is of the Coroutine Builders launch
, async
and runBlocking
. If not, there is plenty of content on Coroutines that you can find on the internet! OK, let’s get started. Here’s what I know.
When are Exceptions thrown or re-thrown?
For a regular function
An exception is thrown by simply calling the throw
operator.
throw IllegalStateException("name cannot be empty")
An exception is re-thrown by a regular function when (1) the code in its body throws an exception or (2) does not catch a received exception from another regular function.
fun doSomething() {
...
throw Exception(msg)
...
}fun String.celciusToFahrenheit(): String {
val c = this.toInt() // toInt may cause an exception
return (32.0 + (c.toDouble() / 5.0) * 9.0).toString()
}
For a suspend function
An exception is re-thrown by a suspend function when (1) the code in its body throws an exception, (2) the code does not catch a received exception from another regular or suspend function or (3) when one of its child-Coroutines finishes with an uncaught exception [see Structured Concurrency].
suspend fun doSomethingRemote() {
...
throw Exception(msg)
...
}suspend fun remoteTempInFahrenheit(): String {
val c = server
.getCelcius()// suspend fun may cause an exception
.toInt() // regular fun may cause an exception
return (32.0 + (c.toDouble() / 5.0) * 9.0).toString()
}suspend fun throwsExceptionBecauseOfAsyncChild() {
coroutineScope {
// This 'async' will cause an exception
// with or without(!) calling 'child.await()',
// because 'child' finishes with an exception.
val child = async { throw SomeSillyException() }
}
...
}suspend fun throwsExceptionBecauseOfLaunchChild() {
coroutineScope {
// This 'launch' will cause an exception,
// because 'child' finishes with an exception.
val child = launch { throw SomeSillyException() }
}
...
}
For a Coroutine
An exception finishes a Coroutine when (1) its code throws an exception, (2) its code does not catch a received exception from another regular or suspend function or (3) when one of its child-Coroutines finishes with an uncaught exception [see Structured Concurrency].
scope.launch {
...
throw Exception(msg)
...
}val remoteTempInFahrenheit: Deferred<String> = scope.async {
val c = server
.getCelcius()// suspend fun may finish this coroutine
.toInt() // regular fun may finish this coroutine
return (32.0 + (c.toDouble() / 5.0) * 9.0).toString()
}scope.launch {
// This 'async' will finish this coroutine
// with or without(!) calling 'child.await()',
// because 'child' finishes with an exception.
val child = async { throw SomeSillyException() }
...
}scope.async {
// This 'launch' will cause finish this coroutine,
// because 'child' finishes with an exception.
val child = launch { throw SomeSillyException() }
...
}
Summary
Both regular functions and suspend
functions re-throw any uncaught exception that was received in the body of those functions. In addition, a suspend
function also re-throws any exception that was not caught by any child-Coroutine launched in its scope.
For a Coroutine, it works a bit different. When its code does not catch an exception or an exception was not caught by the code of any child-Coroutine launched in the same scope, the Coroutine finishes with that uncaught exception. We’ll explain what “finishes with that uncaught exception” means later in the section below.
How are Exceptions handled?
We visited a few scenarios in which exceptions can be thrown and re-thrown. Let’s see how our code could handle them.
For a regular function
There’s nothing new here, we’re very familiar with regular (and blocking) functions. There are two options:
- (1) Use a try & catch clause to catch any thrown exception locally.
fun parseNumber(input: String): Int =
try {
input.parseInt()
} catch (e: Throwable) {
-1
}
- (2) Otherwise, let the Uncaught Exception Handler take care of it.
If the exception is not caught locally, it is thrown up the Thread Call Stack, where the calling code has another chance to catch it locally using a try & catch clause or let it go even higher up the Thread Call Stack until it reaches the Thread’s Uncaught Exception Handler (UEH).
The UEH is a handler installed by a Thread that listens for and handles any exception that is thrown by the code in that Thread but was not properly handled by a try & catch clause. Usually, when an exception arrives at the UEH, a stack-trace is logged and our app may crash and stop.
For a suspend function
The designers of Kotlin’s Coroutines went to great lengths to make sure that there are as few differences as possible between writing a regular function and writing a suspend
function. This also is true when we need to write code that handles exceptions thrown by the code inside a suspend
function.
- (1) Just like with regular functions, use a try & catch clause to catch any thrown exception locally.
For example, if you are making a network call through a regular or suspend function:
suspend fun Network.parseNumber(url: URL): Int =
try {
this.getData(url).parseInt()
} catch (e: NumberFormatException) {
-1
}
or if you are making one through a callback:
suspend fun Network.parseNumber(url: URL): Int =
suspendCancellableCoroutine { cont ->
this.requestData(url) {
try {
cont.resume(it.parseInt())
} catch (e: NumberFormatException) {
cont.resume(-1)
} catch (e: Exception) {
cont.resumeWithException(e)
}
}
}
- (2) Otherwise, let the Coroutine handle it.
If the exception is not caught locally, it is thrown up the Coroutine Call Stack, where the calling code has another chance to catch it locally using a try & catch clause or let it go even higher up the Coroutine Call Stack until it reaches a Coroutine.
A note about the Coroutine Call Stack:
Coroutines do not really have their own call-stack. They use the call-stack of the Threads on which they are dispatched and save and restore their own call-stack state when they suspend and resume. But for simplicity, just imagine Coroutines have their own call-stack and that it behaves similarly to a Thread’s call-stack.
For a Coroutine
As with the other scenarios, we can just catch exceptions locally by using a try & catch clause.
val deferredResult: Deferred<Int> = scope.async {
try {
network.getData(url).parseInt()
} catch (e: NumberFormatException) {
-1
}
}
When we do not catch a thrown exception locally, the calling Coroutine finishes with that exception.
Here we have that phrase again: “Coroutine finishes with an exception.” Let’s finally examine what that means.
Finishing a Coroutine with an exception
When a Coroutine, built by calling CoroutineScope.async
or CoroutineScope.launch
, finishes with an (uncaught) exception, its parent-Coroutine will be finished with that exception, as long as both of them were built within the same CoroutineScope
. This keeps going up the child-parent Coroutine chain until we reach a Coroutine that has no parent-Coroutine (that was built within the same scope). This is called the top-most Coroutine.
The value returned by an async
-Coroutine is returned by async
as a Deferrable
of that value’s type. The caller must call Deferred<T>.await()
to wait and obtain the result until it becomes available. If the async
-Coroutine finishes with an exception, the exception will be the async
-Coroutine’s result.
val deferredResult = async {
// This child-Coroutine throws an exception
throw SomeSillyException()
}textView.rating = try {
deferredResult.await()
} catch (e: SomeSillyException) {
// We catch the exception and assign a default value
-1
}// At this point, this parent-Coroutine has finished with
// 'SomeSillyException' as well, even though we caught
// the exception just now!
We need to be aware that even if we handle the exception by calling await()
, it will still go up the child-parent Coroutine chain.
For a top-most Coroutine created from a call to “async”
Say the top-most async
-Coroutine finishes with an exception. We still can handle the exception, albeit in a different scope (since it was the top-most Coroutine)
val deferredResult = scope1.async {
throw SomeSillyException()
}scope2.launch {
textView.rating = try {
deferredResult.await()
} catch (e: SomeSillyException) {
-1
}
}
Unlike await()
, a call to join()
on the deferred result will not re-throw this exception.
Installing a CoroutineExceptionHandler
(see next section) in the scope of a top-most async
-Coroutine has no effect and the exception will not be handled by the UEH either.
This means that if we don’t call await()
, the uncaught exception is ignored entirely!
For a top-most Coroutine created from a call to “launch”
Any result returned by a launch
-Coroutine is ignored. A launch
is a fire-and-forget type of Coroutine.
Say the top-most launch
-Coroutine finishes with an exception. We have two options to handle the exception:
- Let the UEH handle it
val scope = CoroutineScope()scope.launch {
throw SomeSillyException()
}// Will cause the UEH to be invoked.
// It will most likely print a stack-trace to the error-log
// and crash our app.
- Install a
CoroutineExceptionHandler
into the CoroutineScope
val errorHandler = CoroutineExceptionHandler { context, error ->
when (error) {
is SomeSillyExeption -> ...
...
}
}val scope = CoroutineScope(errorHandler)scope.launch {
throw SomeSillyException()
}// Will cause the 'errorHandler' to be invoked
// and our app won't crash.
Because Coroutines that finish with an exception will eventually finish the top-most Coroutine, installing a CoroutineExceptionHandler
only makes sense if we install it in our top-most CoroutineScope
. Installing this handler in the scope of child-Coroutines has no effect.
val scope = CoroutineScope(errorHandler)scope.launch {
throw SomeSillyException()
}// Will cause the 'errorHandler' to be invoked.
and
val scope = CoroutineScope()scope.launch(errorHandler) {
throw SomeSillyException()
}// Will also cause the 'errorHandler' to be invoked.
but
val scope = CoroutineScope()scope.launch {
launch(errorHandler) {
throw SomeSillyException()
}
}// Will cause the UEH to be invoked instead and our app may crash,
// because the 'errorHandler' is not installed in the
// scope of the top-most Coroutine.
For a new CoroutineScope
A newly created CoroutineScope
allows the launching of the so-called top-most Coroutines that will run concurrently with the code that builds them, by calling CoroutineScope.launch
or CoroutineScope.async
. A CoroutineScope
contains a Job
that will be the parent-Job of these Coroutines.
When one of the launched Coroutines finishes with an exception, the Job
and CoroutineScope
, from which it was launched, finish with that exception.
Any other Coroutines, that were launched earlier, will be cancelled and the CoroutineScope will not be able to launch new ones.
val scope = CoroutineScope(Job())val child1 = scope.async {
...
throw SomeSillyException()
}val child2 = scope.launch {
... this Coroutine may be cancelled ...
}... a while later, after SomeSillyException was thrown ...
val child3 = scope.launch {
... this Coroutine will never start ...
}
CancellationException
OK…? “CancellationException.” What about it?
We saw that when a Coroutine finishes with an exception, the parent-Coroutine finishes with that exception. This process bubbles up all the way to the top-most Coroutine in the scope. This is true for all exceptions, except for ones that are a CancellationException
.
A thrown CancellationException
, or calling cancel()
on the Coroutine’s Job
, only finishes the Coroutine itself. It will not finish its parent Coroutine!
- If the Coroutine is an
async
-Coroutine, it will re-throw theCancellationException
each time and only whenawait()
is called on its deferred result. - If the Coroutine is a top-most
launch
-Coroutine, it will not send theCancellationException
to the CEH nor to the UEH.
Code Examples
Below are a few code examples that show how exceptions are thrown and handled.
The examples are hosted on https://play.kotlinlang.org and that’s why you see the
SHOW EMBED
message in their place. Click the button to reveal the code samples. If one fails to load, try right-clicking on the area and select “Reload Frame”. The code examples are live and you can run them by hitting the green play button.
The call to oops()
causes an exception that is never caught. The top-most Coroutine is a launch
without a CoroutineExceptionHandler
(CEH) installed. The playground will show a crash-log.
This time we installed a CEH. The playground will print out the handler’s message.
We installed a CEH, but we did not install it in the scope of the top-most Coroutine. This means that the CEH will not be used and the playground will show a crash-log.
A CEH has been installed in the right spot again and we launch an async
child-Coroutine that throws an exception. Even though we never call await()
on the deferred result, the uncaught exception from the child-Coroutine finishes its parent — and top-most — Coroutine with the same exception. The CEH will print out its message.
Note that in this example, the call to
launch
takes the CEH as a parameter. As far as thislaunch
-Coroutine goes, it has the same effect as installing one in the scope on which it is called.
The example below is basically the same as the one above. But in the one below we wait for the result by calling await()
and catching the exception. The value -1
will be printed… but also the message from the CEH!
This happens because the async
-Coroutine finishes with an exception, whether we handle that exception later by calling await()
or not. This still causes its parent Coroutine to finish with the exception as well and the CEH will print out its message.
Here we show the same example as the one above, with one little change: We call yield()
just before the println(value)
statement.
The async
-Coroutine finishes with an exception, causing the top-most launch
-Coroutine to finish with an exception as well.
In the previous example there was no suspension point between the try & catch clause and the println statement. The thread just kept running and the value -1
was printed.
In the example below, we now have a suspension point because we call the suspend
function yield()
. Since this Coroutine has finished, the code will never resume after the yield()
statement. The value -1
is never printed and only the CEH prints out its message.
The next example shows how to handle an exception thrown from a suspend
function (oops()
). This is done simply by catching it locally, just like we would do when catching one from a regular function.
Instead of having oops()
throwing an exception in its body, it launches a launch
child-Coroutine that throws an exception.
Because the suspend
fun oops()
now has a child-Coroutine that finishes with an exception, this exception will be re-thrown by oops()
. We catch it locally and return -1
as a result about a second later.
Here is a tricky one! It is the same example as the one above, but instead of launching a child-Coroutine, we launch a top-most Coroutine from a different scope, GlobalScope
in our example.
Here is an exercise. Run the below example and explain what happens in the comments below.
There is a special place for CancellationException
s. This example prints the result 5
and the CEH doesn’t print anything. Why is that? Let us know in the comments.
Recap
Throwing and catching exceptions when dealing with suspend
functions is largely the same as how we deal with them using regular functions. We can use try & catch clauses and exceptions are thrown up a Coroutine Call Stack as they are thrown up a Thread Call Stack.
However, when we start dealing with Coroutines, it gets a little more complicated. Exceptions are bubbled up to parent Coroutines. When they reach the top-most Coroutine, whether the top-most Coroutine was created by a call to launch
or a call to async
determines how they are handled. On top of that, we need to consider whether the exception is a CancellationException
or not.
One more thing… SupervisorJob
Coroutines in this blog post are run with a Job
, which allows the finishing of a Coroutine to finish its parent-Coroutine. When a Coroutine is run with a so-called SuperviserJob
, this does not happen. Read more about Supervision in part II of Exceptional Exceptions.