Developers often spend a lot of time honing their app’s proper functionality, but it’s just as important to give the user the right experience when something unexpected happens. On the one hand, witnessing an app crash is a terrible experience for the user; On the other hand, when the user fails, it must be able to give the correct message.

Correctly handling exceptions can greatly improve the user’s perception of an application. Next, this article explains how exceptions propagate between coroutines and some ways to handle them to help you stay on top of things.

⚠ ️ in order to be able to better understand about the contents of this article, it is recommended that you first read the first article in this series: cancel and exception of coroutines | core concept is introduced.

What if a coroutine suddenly fails? 😱

When a coroutine fails due to an exception, it propagates the exception and passes it to its parent. Next, the parent does the following:

  • Cancels its own children;
  • Cancel itself;
  • The exception is propagated and passed to its parent.

The exception reaches the root of the hierarchy, and all coroutines started by the current CoroutineScope are cancelled.

Exceptions in a coroutine propagate through the coroutine hierarchy

While this propagation logic makes perfect sense in some cases, you might not think so in other cases. Suppose your application has a CoroutineScope associated with the UI to handle interactions with users. If one of its subcoroutines throws an exception, the UI Scope is cancelled and all UI components become unresponsive because the cancelled scope cannot open a new coroutine.

If you don’t want this to happen, you can try to use the Job’s other extension, SupervisorJob, in the CoroutineContext of the CoroutineScope when you create the coroutine.

SupervisorJob is used to solve problems

When it is used, the failure of one subcoroutine does not affect the other subcoroutines. The SupervisorJob does not cancel it and its children, and it does not propagate exceptions to its parents. It lets the children handle exceptions themselves.

You can use this code to create a CoroutineScope: val uiScope = CoroutineScope(SupervisorJob()) so that it doesn’t propagate the cancellation if the coroutine fails, as shown in the image below.

The SupervisorJob is not designed to cancel any of its children

If an exception is not processing, and none CoroutineContext CoroutineExceptionHandler (later), abnormal ExceptionHandler reaches the default thread. In the JVM, exceptions are printed on the console; In Android, whether an exception occurs in that Dispatcher, it will crash your application.

💥 Uncaught exceptions will always be thrown, no matter which Job you use

Using coroutineScope and supervisorScope has the same effect. They create a child scope (with a Job or SupervisorJob as the parent) that helps you organize coroutines according to your own logic (for example, when you want to do a set of parallel computations and want them to affect each other or be happy).

Supervisorjobs are only designed to work when they are part of the supervisorScope or CoroutineScope(SupervisorJob()).

** SupervisorJob; ** SupervisorJob; * * 🤔

When should you use the Job or SupervisorJob? If you want to avoid exiting the parent and other lateral coroutines when an error occurs, use the SupervisorJob or supervisorScope.

The following is an example:

// Scope controls a level of coroutines in my application
val scope = CoroutineScope(SupervisorJob())

scope.launch {
    // Child 1
}

scope.launch {
    // Child 2
}
Copy the code

If Child 1 fails in this example, both Scope and Child 2 are cancelled.

Here’s another example:

// Scope controls a level of coroutines in my application
val scope = CoroutineScope(Job())

scope.launch {
    supervisorScope {
        launch {
            // Child 1
        }
        launch {
            // Child 2}}}Copy the code

In this example, Child 2 is not cancelled if Child 1 fails, because it is created by the supervisorScope using the SupervisorJob. If you use the coroutineScope in the extension instead of the supervisorScope, the error will be propagated and the scope will eventually be cancelled.

Quiz: Who is my father? 🎯

Given the following code, you can indicate the Child1What kind of Job is the parent?val scope = CoroutineScope(Job())

scope.launch(SupervisorJob()) {
    // new coroutine -> can suspend
   launch {
        // Child 1
    }
    launch {
        // Child 2}}Copy the code

Child 1’s parent Job is of the Job type only! I hope you’re right. It’s supposed to be a SupervisorJob, but it’s not supposed to be because the new coroutine is created, and it generates new instances of the Job instead of the SupervisorJob. The SupervisorJob in this example is created by the coroutine’s parent via scope.launch, so the truth is that it is completely useless in this code!

It is designed to be a Job, and it is not designed to SupervisorJob

In this way, whether Child 1 or Child 2 fails, the error reaches the scope and all coroutines opened in that scope are cancelled.

Remember, it only works when it is created using the supervisorScope or CoroutineScope(SupervisorJob()). The Builder that passes the SupervisorJob as a parameter to a coroutine does not give you the desired effect.

The working principle of

If you’re confused about the underlying implementation of jobs, check out the extensions to the childCancelled and notifyCancelling methods in the jobsupport.kt file.

In the SupervisorJob extension, the childCancelled method simply returns false, meaning it won’t propagate the cancellation and won’t do anything to handle the handler.

Exception 👩🚒

Coroutines handle exceptions using the usual Kotlin syntax: try/catch or built-in utility methods such as runCatching (which still uses try/catch internally)

As mentioned earlier, all uncaught exceptions must be thrown. However, different coroutine Builders handle exceptions in different ways.

Launch

With launch, exceptions are thrown as soon as they happen, so you can wrap the code that throws the exception in a try/catch, as in the following example:

scope.launch {
    try {
        codeThatCanThrowExceptions()
    } catch(e: Exception) {
        // Handle the exception}}Copy the code

With launch, exceptions are thrown as soon as they occur

Async

Async is not automatically thrown when it is used as a root coroutine (a direct subroutine of a CoroutineScope instance or supervisorScope). It is thrown when you call.await().

To catch the exceptions thrown by async when it is the root coroutine, you can wrap the code calling.await() with a try/catch:

supervisorScope {
    val deferred = async {
        codeThatCanThrowExceptions()
    }
 
   try {
       deferred.await()
   } catch(e: Exception) {
       // Handle exceptions thrown by async}}Copy the code

Note that in this case, async never throws an exception. That’s why it’s not necessary to wrap it in a try/catch as well. The await will throw all the exceptions generated in the Async coroutine.

When async is used as the root coroutine, exceptions will be thrown when you call the.await method

The other thing to notice here is that we’re using the supervisorScope to call async and await. As we mentioned earlier, the SupervisorJob lets the coroutine handle its own exceptions; In contrast, the Job automatically propagates an exception across the hierarchy so that the catch part of the block is not called:

coroutineScope {
   try {
       val deferred = async {
           codeThatCanThrowExceptions()
       }
       deferred.await()
   } catch(e: Exception) {
       // Exceptions thrown by async will not be caught here
       // However, exceptions are propagated and passed to scope}}Copy the code

Further, exceptions generated in coroutines created by other coroutines will always be propagated, regardless of the coroutine’s Builder. Such as:

val scope = CoroutineScope(Job())
scope.launch {
   async {
       // If async throws an exception, launch will immediately throw the exception without calling.await().}}Copy the code

In this case, since scope’s direct subcoroutine is launch, if an exception is generated in async, it will be thrown immediately. The reason is that async (which contains a Job in its CoroutineContext) automatically propagates an exception to its parent (launch), which causes the exception to be thrown immediately.

⚠️ Exceptions thrown in the coroutineScope Builder or in coroutines created by other coroutines are not caught by try/catch!

In the previous SupervisorJob that section, we mentioned CoroutineExceptionHandler exist. Now let’s dig deeper.

CoroutineExceptionHandler

CoroutineExceptionHandler CoroutineContext is an optional element, it allows you to handle an uncaught exception.

Here’s how to declare a CoroutineExceptionHandler example. Wherever an exception is caught, you can use handler to get information about the CoroutineContext in which the exception is located and the exception itself:

val handler = CoroutineExceptionHandler {
   context, exception -> println("Caught $exception")}Copy the code

Exceptions are caught when the following conditions are met:

  • Timing ⏰: Exceptions are thrown by coroutines that automatically throw exceptions (using launch, not async);

  • Location 🌍: in the CoroutineContext of the CoroutineScope or in a root coroutine (a direct subcoroutine of the CoroutineScope or supervisorScope).

Let’s look at some examples of using CoroutineExceptionHandler. In the following code, the exception is caught by the handler:

val scope = CoroutineScope(Job())
scope.launch(handler) {
   launch {
       throw Exception("Failed coroutine")}}Copy the code

In another example, handler is installed as an internal coroutine, so it will not catch exceptions:

val scope = CoroutineScope(Job())
scope.launch {
   launch(handler) {
       throw Exception("Failed coroutine")}}Copy the code

The reason the exception is not caught is because the handler is not installed with the correct CoroutineContext. The inner coroutine propagates the exception when it occurs and passes it to its parent. Since the parent is unaware of the handler, the exception is not thrown.

Handling exceptions gracefully in a program is key to providing a good user experience, especially when things don’t go as expected.

If you want to avoid the cancellation operation being propagated when an exception occurs, remember to use the SupervisorJob. Otherwise, use Job.

Exceptions that are not caught are propagated, catching them to ensure a good user experience!

We’ll continue to update this series over the next few days, so if you’re interested, stay tuned.