Exceptional Exceptions for Coroutines made easy…?

Anton Spaans
Jul 1, 2019 · 11 min read

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.

Image for post
Image for post
“Kernel Panic” by Kevin Collins.

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 launching 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 the CancellationException each time and only when await() is called on its deferred result.
  • If the Coroutine is a top-most launch-Coroutine, it will not send the CancellationException 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 this launch-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 CancellationExceptions. 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.

The Kotlin Chronicle

The Kotlin Chronicle is a publication which covers all…

Anton Spaans

Written by

Principal Software Engineer at @intpd, part of @Accenture. You can find me online @streetsofboston or at https://www.linkedin.com/in/antonspaans/

The Kotlin Chronicle

The Kotlin Chronicle is a publication which covers all things related to the Kotlin programming language and the community around it.

Anton Spaans

Written by

Principal Software Engineer at @intpd, part of @Accenture. You can find me online @streetsofboston or at https://www.linkedin.com/in/antonspaans/

The Kotlin Chronicle

The Kotlin Chronicle is a publication which covers all things related to the Kotlin programming language and the community around it.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch

Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore

Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store