Translator’s Words:

This is a translation of Kotlin coroutine exception handling article, very clear answers to some of my doubts, Why exception handling with Kotlin Coroutines is so hard and how to successfully master it! Kotlin Coroutines: 7 Mistakes you might make with Kotlin Coroutines

In this paper, six coroutine exception handling points are obtained through code examples. It should be noted that the word “re-thrown” appears several times in the exception handling, indicating that an exception occurs in a function and is thrown up the call stack, which is how Kotlin exceptions propagate when we don’t use coroutines. Unlike the structured concurrency of Kotlin coroutines, where exceptions are propagated up the Job hierarchy, the term is not translated below.

Let’s begin the translation.

Exception handling can be one of the most difficult parts of learning coroutines. In this blog post, I will describe the reasons for its complexity and provide some points to better understand the topic. Learn how to handle exceptions correctly in your own APP.

Kotlin’s exception handling without coroutines

Exception handling in pure Kotlin code (without coroutines) is very simple, and we basically just use try-catch statements 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'
Copy the code

If an exception is thrown in a normal function, the exception is re-thrown by that function. This means that we can use try-catch statements to handle exceptions at the calling location:

fun main(a) {
    try {
        functionThatThrows()
    } catch (exception: Exception) {
        println("Handle $exception")}}fun functionThatThrows(a) {
    // some code
    throw RuntimeException("RuntimeException in regular function")}/ / output:
// Handle java.lang.RuntimeException: RuntimeException in regular function
Copy the code

Coroutines intry-catch

Now let’s look at try-catch in coroutines. Using try-catch inside a coroutine (where launch begins in the following example) results in the exception being caught as we expect:

fun main(a) {
    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
Copy the code

But when we launch another coroutine in a try block

fun main(a) {
    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
Copy the code

Surprisingly, you can see in the output that the exception is no longer handled and the application crashes. Because in our experience, in try-catch statements, we expect that every exception in the try block will be caught and go into the catch block. But why isn’t everything up there as expected?

This is because the coroutine itself (as opposed to within it) does not catch exceptions through try-catch statements. In the example above, the coroutine starts with the inner launch, and it doesn’t catch RuntimeException, so it crashes, simple as that.

As we saw at the beginning, uncaught exceptions in normal functions continue re-thrown. This is not analogous to a coroutine that does not catch exceptions. Otherwise, we will be able to handle the exception externally, and the application in the above example will not crash.

So what happens to uncaught exceptions in coroutines? Structured concurrency, one of the most innovative features of coroutines, addresses this problem. The feature of structured concurrency is realized through the Job object of CoroutineScope, the Job object in coroutine, and the hierarchy of coroutine parent-child relationship. Uncaught exceptions will not be thrown up re-thrown, but propagated in the Job hierarchy. This exception propagation causes the parent Job to fail, so all child jobs are cancelled.

The Job hierarchy of the above code looks like this:

First the child coroutine’s exception propagates to the top-level coroutineJobAnd then spread totopLevelScopetheJob.

Can be configured CoroutineExceptionHandler to deal with the spread of the exception. If not configured CoroutineExceptionHandler, uncaught exception handling procedure of the calling thread (UncaughtExceptionHandler), and this will depend on different platforms, may lead to abnormal print, and termination of the APP.

I think, the fact is that we have two different try-catch and CoroutineExceptionHandler exception handling mechanism, it is one of the main reasons for coroutines exception handling complex.

The point 1

If the coroutine is not used internallytry-catchStatement handles an exception, then the exception is not re-thrown and therefore cannot be thrown externallytry-catchStatement processing. On the contrary, the exception is “communication” in the Job hierarchy, can also be configured by CoroutineExceptionHandler processing. If not configured CoroutineExceptionHandler, abnormal will arriveUncaughtExceptionHandler.


CoroutineExceptionHandler

We now know that a try-catch statement is invalid for a coroutine that throws an exception when started in a try block. Therefore, we want to use CoroutineExceptionHandler instead! We can pass the context to the Launch coroutine builder. Because CoroutineExceptionHandler is ContextElement, we can launch configuration when promoter coroutines CoroutineExceptionHandler:

fun main(a) {
    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
Copy the code

However, in the above example of the anomaly has not been coroutineExceptionHandler processing, so the APP crash! This is because the child to configure CoroutineExceptionHandler on coroutines does not produce any results. We must configure CoroutineExceptionHandler in top coroutines scope or coroutines, as shown below:

// ...
val topLevelScope = CoroutineScope(Job() + coroutineExceptionHandler)
// ...
Copy the code

Or something like this:

// ...
topLevelScope.launch(coroutineExceptionHandler) {
// ...
Copy the code

Only in this way can CoroutineExceptionHandler handle exceptions:

// ..
/ / output:
// Handle java.lang.RuntimeException: RuntimeException in nested coroutine in CoroutineExceptionHandler
Copy the code

The point 2

In order to make the CoroutineExceptionHandler effect, must be configured in CoroutineScope or top-level coroutines.


try-catch VS CoroutineExceptionHandler

As shown in the above, we have two ways to handle exceptions: will the code with a try-catch coroutines CoroutineExceptionHandler wrapped up or configuration. So how do we decide?

CoroutineExceptionHandler official documentation provides a good answer:

“CoroutineExceptionHandler is a necessity of global trapping mechanism. In CoroutineExceptionHandler, unable to recover from the abnormal. When calling CoroutineExceptionHandler coroutines also accompanied by corresponding abnormal end. Usually, CoroutineExceptionHandler for output abnormal log, display an error message, stop or restart the application.

If you need to handle exceptions in a specific part of your code, it is recommended to wrap the code in a try/catch inside the coroutine. This way, you can avoid the abnormal termination of the coroutine, retry the operation, or take other actions.”

The other aspect I want to mention here is that by handling exceptions directly in coroutines, try-catch we don’t take advantage of the cancellations associated with structured concurrency. For example, let’s suppose we start two coroutines in parallel. They both depend on each other, and if one fails, there is no point in completing the other. If we now handle exceptions with a try-catch in each coroutine, the exception is not propagated to the parent, so the other coroutines are not cancelled. And that’s a waste of resources. In this case, we should use CoroutineExceptionHandler.

The point 3

If you want to retry or do something else before the coroutine completes, try/catch is used. Keep in mind that the exception is caught directly in the coroutine, it is not propagated in the Job hierarchy, and the cancellation of structured concurrency is not used. Using CoroutineExceptionHandler logic occurs after the completion of a coroutines.


launch{}async{}

So far, we’ve only used launch to launch new coroutines. However, the exception handling of a coroutine launched by launch is completely different from that of an async coroutine. Let’s look at the following example:

fun main(a) {
    val topLevelScope = CoroutineScope(SupervisorJob())

    topLevelScope.async {
        throw RuntimeException("RuntimeException in async coroutine")
    }

    Thread.sleep(100)}/ / no output
Copy the code

This example has no output. So what happens with RuntimeException? Was it simply ignored? Not at all. In an async initiated coroutine, uncaught exceptions are also immediately propagated in the Job hierarchy. But contrary to launch start coroutines, abnormal not by the configured CoroutineExceptionHandler processing, also won’t transfer to UncaughtExceptionHandler.

The return type of the launch coroutine is Job and has no return value. If you need some result of a coroutine, you use Async, which returns a Deferred, a special type of Job that holds an additional result. If the async coroutine fails, wrap the exception in the Deferred return type and rethrow it when we call the suspend function.await().

Therefore, we can wrap.await() with a try-catch statement. Since.await() is a suspend function, we want to start a new coroutine to call it:

fun main(a) {
    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
Copy the code

Note: If the Async coroutine is a top-level coroutine, only the exception is wrapped in the Deferred. Otherwise, even if the wrong call. Await (), abnormal immediately will also spread to the Job hierarchy and handled by CoroutineExceptionHandler or passed to UncaughtExceptionHandler, as shown in the example below:

fun main(a) {
    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
Copy the code

The point 4

Uncaught exceptions in launch and Async coroutines are immediately propagated in the Job hierarchy. However, if the launch the setup is the top-level coroutines, the exception will be handled by CoroutineExceptionHandler or passed to UncaughtExceptionHandler. On the other hand, if it is a top-level coroutine that async starts, the exception is encapsulated in the Deferred return type and re-thrown when.await() is called.


coroutineScope{}Exception handling of

When we discussed try-catch and coroutines earlier, failed coroutines propagated their exceptions into the Job hierarchy rather than re-thrown, so try-catch outside the coroutine was invalid.

But something interesting happens when we wrap the failed coroutine with the coroutineScope{} scope function:

fun main(a) {
  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
Copy the code

We are now ready to handle exceptions using try-catch statements. Therefore, the scope function coroutineScope{} throws an exception up rather than propagating it into the Job hierarchy.

CoroutineScope {} is used to implement “parallel decomposition” functions in the suspend function. These suspend functions re-throw the exceptions of the coroutine, so we can write exception handling logic accordingly.

The point 5

Scope functioncoroutineScope{}Re-thrown exceptions to its failed subcoroutines, rather than propagating them into the Job hierarchy, which allows us to usetry-catchTo handle coroutine exceptions


supervisorScope{}Exception handling of

We’re handling the Job hierarchy with the scope function supervisorScope{}, which is designed to create a new, separate, nested scope that the coroutine scope is designed to handle as the Job.

fun main(a) {
    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)}Copy the code

The above code creates the following Job hierarchy:

The key to understanding exception handling here is that it’s a new, separate subscope that has to handle exceptions independently. It does not throw an exception up, as coroutineScope does, nor does it propagate to the parent scope, the Job in topLevelScope.

The other important thing to understand is that exceptions are only propagated upwards until they reach the top scope or container container. This means that coroutines 2 and 3 are now top-level coroutines.

This also means that we can now for these top coroutines CoroutineExceptionHandler configuration:

fun main(a) {
    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
Copy the code

The coroutine that is launched directly in the container is seen as a top-level coroutine, which means that the one async is launched in, wraps the exception in a Deferred object and is only thrown upwards when.await() is called.

/ /... The omission code is the same as 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
Copy the code

The point 6

Scope functionsupervisorScope{} A new independent subscope is configured in the Job hierarchy and is usedSupervisorJobJob as scoped. This new scope does not propagate exceptions in the Job hierarchy, so it handles its own exceptions. Directly from thesupervisorScopeCoroutines started in are top-level coroutines. The top-level coroutine is in thelaunch()orasync()The startup behavior differs from that of a subcoroutine. It can also be configured in a top-level coroutineCoroutineExceptionHandlersTo handle exceptions.