Why exception handling with Kotlin Coroutines is so hard and how to successfully master it!

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.

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 Jobs 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 CoroutineExceptionHandlers – 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.


📬 Get notified whenever I publish new content! 🚀

No Spam! Unsubscribe at any time!


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 the CoroutineExceptionHandler. 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 supervisorScopeis 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 on Android:


📬 Get notified whenever I publish new content! 🚀

No Spam! Unsubscribe at any time!


 

Lukas Lechner

I'm a Freelance Android Developer and Online Course Creator. I have many years of professional experience as an Android Developer and worked on some of the biggest mobile apps projects here in Austria. I like to write articles for my blog and speak at meetups and conferences.