Mastering Exception Handling in Kotlin Coroutines


In this blog, we explore the essential topic of exception handling in Kotlin Coroutines. Unlike traditional methods, coroutines require specific strategies to manage errors effectively. We will cover key concepts such as CoroutineExceptionHandler, cancellation exceptions, and the use of SupervisorJob for structured exception handling. By understanding these techniques, developers can ensure their applications remain stable and resilient, even in the face of unexpected errors. Join us as we break down these important practices for robust coroutine management.

Exception handling in Kotlin Coroutines is similar to the traditional exception handling with the try-catch blocks but with some key differences.

Traditional Exceptional Handling

Let’s run a simple coroutine which just throws a custom exception and then try to catch that exception using a try-catch block

fun basicExceptionHandling() = runBlocking {
    val job = launch {
        try {
            throw Exception("My exception")
        } catch (e: Exception) {
            println("Caught exception: $e")
        }
    }
    job.join()
}

Run this code and you will see the following output

Caught exception: java.lang.Exception: My exception

This is the traditional way but Kotlin provides us a few more ways to handle our exceptions for coroutines. Let’s understand those key concepts in detail.


Key Concepts of Coroutine Exception handling

There are 5 main concepts that Kotlin offers namely:

  1. Exception propagation
  2. CoroutineExceptionHandler
  3. Handling Cancellation exceptions
  4. Exception aggregation
  5. Supervision

Let’s understand each of these Key concepts in detail.

Exception Propagation

As we have seen in our previous blogs, Kotlin offers us two main coroutine builders: launch and async. Each of these builders handle the exceptions a bit differently

launch

As we know, launch is used to start a background task that you don’t necessarily need to wait for. When an uncaught exception occurs in a root launch coroutine, it is treated similarly to an uncaught exception in a regular thread.

@OptIn(DelicateCoroutinesApi::class)
fun uncaughtExceptionInLaunch() = runBlocking {
    val job = GlobalScope.launch {
        println("About to throw my custom exception")
        throw Exception("My exception")
    }
    job.join()
    println("Job complete")
}

Output:

As you can see in the output, an uncaught exception in a launch builder crashes the worker thread (“DefaultDispatcher- worker- 1”). This is because launch doesn’t propagate the exception immediately to the caller. The job.join() suspends the calling scope (runBlocking scope in our case) until the launch coroutine completes, and that’s when the exception surfaces and crashes the thread.

Note: If we didn’t call job.join(), there would be no crash and the output will look something like this:

Job complete
About to throw my custom exception

Now let’s see what happens if the child launch coroutine throws an exception:

@OptIn(DelicateCoroutinesApi::class)
fun propagateExceptionInLaunch() = runBlocking {
    GlobalScope.launch {
        println("Inside parent coroutine")
        launch(Dispatchers.IO) {
            println("Inside child coroutine")
            launch {
                println("Inside sub-child coroutine")
                throw Exception("My Exception")
            }
        }
    }.join()
    println("Job completed")
}

Output:

ASYNC

As we know, async returns a result wrapped in a Deferred object. So exceptions are also encapsulated within the Deferred object. We need to call await() to either get the result or retrieve the exception.

@OptIn(DelicateCoroutinesApi::class)
fun uncaughtExceptionInAsync() = runBlocking {
    val deferred = GlobalScope.async {
        println("async: About to throw my custom exception")
        throw Exception("My exception")
    }
    deferred.await()
    println("Job complete")
}

Output:

So what happened here??

This exception is different from the one we saw in launch. This time our main thread crashed. Why this difference??

The difference stems from the intended use cases of launch and async. launch is fire-and-forget tasks, so uncaught exceptions are treated as critical errors. async is for result-returning tasks, and it’s up to the caller to decide when and how to handle potential exceptions by calling await().

Note: If you don’t call join() for launch or await() for async builder, the coroutines will run in the background and any uncaught exceptions might not be immediately visible and can lead to subtle bugs and unexpected behavior.

Now, let’s see what happens if the exception is thrown by a child coroutine:

@OptIn(DelicateCoroutinesApi::class)
fun propagateExceptionInAsync() = runBlocking {
    GlobalScope.async {
        println("Inside parent coroutine")
        async (Dispatchers.IO) {
            println("Inside child coroutine")
            async {
                println("Inside sub-child coroutine")
                throw Exception("My Exception")
            }
        }
    }.await()
    println("Job completed")
}

Output:


CoroutineExceptionHandler

As discussed in the Coroutine context blog, one key element of the coroutine context is the CoroutineExceptionHandler. This handler serves as a generic catch block for uncaught exceptions occurring in both the root coroutine and its child coroutines.

The CoroutineExceptionHandler is invoked just before the job completes, indicating that recovery from the exception is not possible. Once this generic catch block is called, the job will conclude immediately. This handler primarily serves the purpose of logging the exception or facilitating termination, essentially marking the final steps in the coroutine’s lifecycle.

It is important to note that this handler is applicable only if the root coroutine is launched using a launch block. In contrast, it does not have any effect when the root coroutine is defined with an async block, as it only responds to uncaught exceptions.

Now, let’s explore an example to see the output when using a CoroutineExceptionHandler with both launch and async blocks.

Launch
@OptIn(DelicateCoroutinesApi::class)
fun catchExceptionUsingHandlerInLaunch() = runBlocking {
    val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("Caught exception: $throwable")
    }
    val job = GlobalScope.launch (exceptionHandler) {
        println("Inside parent coroutine")
        launch {
            println("Inside child coroutine")
            throw Exception("My exception")
        }
    }
    job.join()
    println("Job completed")
}

Output:

ASYNC
@OptIn(DelicateCoroutinesApi::class)
fun catchExceptionUsingHandlerInAsync() = runBlocking {
    val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("Caught exception: $throwable")
    }
    val deferred = GlobalScope.async(exceptionHandler) {
        println("Inside parent coroutine")
        async {
            println("Inside child coroutine")
            throw Exception("My exception")
        }
    }
    deferred.await()
    println("Job completed")
}

Output:

Even though we used exception handler here but our app still crashed because handler has no effect on async.


Cancellations and Exceptions

When a coroutine is cancelled, it throws a CancellationException. Unlike regular exceptions, all handlers ignore this CancellationException, treating it differently. The CoroutineExceptionHandler functions similarly to a catch block, but only for exceptions other than cancellation.

When a coroutine throws a CancellationException, its parent coroutine remains unaffected and continues executing. However, if the coroutine throws any exception other than CancellationException, the parent coroutine is also cancelled along with that exception. This behaviour is integral to coroutines’ structured concurrency model and cannot be overridden, ensuring that the foundational principles of coroutine structure are preserved.

Let’s explore this concept with two examples: the first will demonstrate a child coroutine throwing a CancellationException, while the second will illustrate a child coroutine throwing a custom exception.

Child coroutine throws Cancellation Exception

fun cancellationExceptionInChildCoroutine() = runBlocking {
    CoroutineScope(Dispatchers.IO).launch {
        println("Start parent coroutine")
        val job1 = launch {
            println("Start child coroutine 1")
            delay(200)
            println("Child coroutine 1 cancelled")
        }

        launch {
            println("Start child coroutine 2")
            delay(300)
            println("Child coroutine 1 was cancelled")
        }
        delay(50)
        job1.cancelAndJoin()
        delay(500)
        println("End parent coroutine")
    }.join()
}

Output:

Child Coroutine throws any other exception

fun otherExceptionInChildCoroutine() = runBlocking {
    CoroutineScope(Dispatchers.IO).launch {
        println("Start parent coroutine")
        launch {
            println("Start child coroutine 1")
            delay(100)
            throw IOException()
        }

        launch {
            println("Start child coroutine 2")
            delay(300)
            println("Child coroutine 1 was cancelled")
        }
        delay(500)
        println("End parent coroutine")
    }.join()
}

Output:


Exception Aggregation

Suppose a parent coroutine contains two child coroutines, and both of them throw exceptions. In this scenario, the rule is that the first exception encountered prevails. The logs will display the first exception, while any subsequent exceptions will be listed as suppressed exceptions.

Let’s illustrate this with an example:

fun exceptionAggregation() = runBlocking {
    val handler = CoroutineExceptionHandler { context, throwable ->
        println("Exception caught: $throwable with suppressed ${throwable.suppressed.contentToString()}")
    }
    CoroutineScope(Job() + handler).launch {
        launch {
            println("Start child coroutine 1")
            delay(100)
            throw IOException()
        }

        launch {
            try {
                println("Start child coroutine 2")
                delay(300)
            } finally {
                throw ArithmeticException()
            }
        }
    }.join()
}

Output:


Supervision

As we saw earlier, by default, coroutines have bidirectional cancellation. This means that if a child coroutine throws an exception, parent coroutine will be cancelled and if the parent coroutine is cancelled, all child coroutines will be cancelled.

But in real life projects, there can be scenarios where we need unidirectional cancellation. Let’s understand a scenario where unidirectional flow will be required. Let’s say we have multiple APIs being called in a scope and if one of the APIs throws an exception, we would not want the complete scope to be cancelled. But if the parent scope is cancelled, we would want all the child coroutines to be cancelled.

This can be implemented using 2 ways:

Supervisor Job

Supervisor Job is similar to a normal Job with the only difference that the exception is propagated only downwards.

Let’s checkout an example for the same:

fun supervisorJob() = runBlocking {
    val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("Caught exception: $throwable")
    }
    with(CoroutineScope(SupervisorJob() + exceptionHandler)) {
        println("Start parent coroutine")
        launch {
            println("Start child coroutine 1")
            delay(100)
            throw IOException()
        }

        launch {
            println("Start child coroutine 2")
            delay(300)
            println("Child coroutine 1 was cancelled but coroutine 2 is running")
        }

        delay(1000)
        println("End parent coroutine")
    }
}

Output:

We can see in the output here that even if the first child coroutine got cancelled because an IOException occurred but the second child coroutine kept running and the parent coroutine completed when both it’s child coroutine completed.

A few important points to note here:

  1. In real life projects, we should handle the exception using CoroutineExceptionHandler in the parent scope. I didn’t use it here just for explanation purpose
  2. In the example, we are using with(CoroutineScope(SupervisorJob())). If we would have used CoroutineScope(SupervisorJob()).launch{}, then our unidirectional cancellation wouldn’t have worked. This is because:
    • CoroutineScope.launch launches a new coroutine within that scope.
    • The code block inside the launch will be executed in a separate child coroutine created by CoroutineScope(SupervisorJob()).
    • The SupervisorJob in the parent coroutine prevents the parent from failing but it doesn’t prevent the child coroutine created by launch from cancelling because of the exception.
Supervisor Scope

We can achieve the same unidirectional cancellation using supervisorScope.

fun usingSupervisorScope() = runBlocking {
    val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        println("Caught exception: $throwable")
    }
    supervisorScope {
        println("Start parent coroutine")
        launch(exceptionHandler) {
            println("Start child coroutine 1")
            delay(100)
            throw IOException()
        }

        launch {
            println("Start child coroutine 2")
            delay(300)
            println("Child coroutine 1 was cancelled but coroutine 2 is running")
        }

        delay(1000)
        println("End parent coroutine")
    }
}

Output:

Note: Since, we used the exceptionHandler here, our coroutine thread didn’t crash.


That’s it for this article. Hope it was helpful! If you like it, please hit like.

Other articles of this series:


Leave a comment