Fading Coder

One Final Commit for the Last Sprint

Home > Notes > Content

Exception Handling in Kotlin Coroutines

Notes 1

This section covers exception handling and cancellation in the context of exceptions. We already know that cancelling a coroutine throws a CancellationException at suspension points, which is ignored by the coroutine mechanism. Here, we'll explore what happens when exceptions are thrown during cancellation or when multiple child coroutines within the same parent throw exceptions.

Exception Propagation

Coroutine builders come in two forms: those that automatically propagate exceptions (like launch and actor) and those that expose exceptions to the user (like async and produce). When these builders create a root coroutine (i.e., a coroutine that is not a child of another coroutine), the former treat exceptions as uncaught, similar to Java's Thread.uncaughtExceptionHandler, while the latter rely on the user to consume the exception, such as via await or receive (the produce and receive functions are covered in the channels chapter).

Here's a simple example using GlobalScope to create root coroutines:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = GlobalScope.launch { // launch a root coroutine
        println("Throwing exception from launch")
        throw IndexOutOfBoundsException() // Will be printed to console via Thread.defaultUncaughtExceptionHandler
    }
    job.join()
    println("Joined failed job")
    
    val deferred = GlobalScope.async { // async root coroutine
        println("Throwing exception from async")
        throw ArithmeticException() // Nothing printed; relies on user to call await
    }
    try {
        deferred.await()
        println("Unreached")
    } catch (e: ArithmeticException) {
        println("Caught ArithmeticException")
    }
}

Output (with debugging):

Throwing exception from launch
Exception in thread "DefaultDispatcher-worker-2 @coroutine#2" java.lang.IndexOutOfBoundsException
Joined failed job
Throwing exception from async
Caught ArithmeticException

CoroutineExceptionHandler

You can customize the default behavior of printing uncaught exceptions to the console. The CoroutineExceptionHandler context element on a root coroutine serves as a generic catch block for that coroutine and all its children, allowing custom exception handling. It's similar to Thread.uncaughtExceptionHandler. You cannot recover from the exception in the CoroutineExceptionHandler; the coroutine has already completed with the exception when the handler is invoked. Typically, the handler logs the exception, displays an error message, terminates, or restarts the application.

On the JVM, you can redefine a global exception handler to register all coroutines via ServiceLoader to CoroutineExceptionHandler. The global handler is used when no more specific handlers are registered, similar to Thread.defaultUncaughtExceptionHandler. In Android, uncaughtExceptionPreHandler is set in the global coroutine exception handler.

CoroutineExceptionHandler is invoked only for uncaught exceptions—those not handled in any other way. Specifically, all child coroutines (created in the context of another Job) delegate exception handling to their parent, which propagates up to the root, so any CoroutineExceptionHandler installed in their context is never used. Additionally, the async builder always catches all exceptions and represents them in the resulting Deferred object, so its CoroutineExceptionHandler has no effect.

Coroutines running in a supervision scope do not propagate exceptions to their parent and are excluded from this rule. The Supervision section below provides more details.

Example:

val handler = CoroutineExceptionHandler { _, exception ->
    println("CoroutineExceptionHandler got $exception")
}
val job = GlobalScope.launch(handler) { // root coroutine in GlobalScope
    throw AssertionError()
}
val deferred = GlobalScope.async(handler) { // also root, but async instead of launch
    throw ArithmeticException() // Nothing printed; relies on user to call deferred.await()
}
joinAll(job, deferred)

Output:

CoroutineExceptionHandler got java.lang.AssertionError

Cancellation and Exceptions

Cancellation is closely related to exceptions. Coroutines internal use CancellationException for cancellation, which is ignored by all handlers, so exceptions that can be caught in catch blocks should only be used as resources for additional debugging information. When a coroutine is cancelled via Job.cancel, it terminates but does not cancel its parant.

Example:

val job = launch {
    val child = launch {
        try {
            delay(Long.MAX_VALUE)
        } finally {
            println("Child is cancelled")
        }
    }
    yield()
    println("Cancelling child")
    child.cancel()
    child.join()
    yield()
    println("Parent is not cancelled")
}
job.join()

Output:

Cancelling child
Child is cancelled
Parent is not cancelled

If a coroutine encounters an exception other than CancellationException, it cancels its parent with that exception. This behavior cannot be overridden and is used to provide a stable coroutine hierarchy for structured concurrency. CoroutineExceptionHandler implementations are not intended for child coroutines.

In this example, CoroutineExceptionHandler is always set on coroutines launched via GlobalScope. Setting an exception handler in a coroutine launched within the runBlocking main scope is meaningless because the main coroutine will always be cancelled, even if child coroutines have exception handlers set.

When all child coroutines of a parent have finished, the original exception is handled by the parent, as shown in this example:

val handler = CoroutineExceptionHandler { _, exception ->
    println("CoroutineExceptionHandler got $exception")
}
val job = GlobalScope.launch(handler) {
    launch { // first child
        try {
            delay(Long.MAX_VALUE)
        } finally {
            withContext(NonCancellable) {
                println("Children are cancelled, but exception is not handled until all children terminate")
                delay(100)
                println("The first child finished its non-cancellable block")
            }
        }
    }
    launch { // second child
        delay(10)
        println("Second child throws an exception")
        throw ArithmeticException()
    }
}
job.join()

Output:

Second child throws an exception
Children are cancelled, but exception is not handled until all children terminate
The first child finished its non-cancellable block
CoroutineExceptionHandler got java.lang.ArithmeticException

Exception Aggregation

When multiple child coroutines fail due to exceptions, the general rule is "first exception wins," so the first exception is handled. All other exceptions that occur after the first are bound to it as suppressed exceptions.

Example:

import kotlinx.coroutines.*
import java.io.*

fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception ->
        println("CoroutineExceptionHandler got $exception with suppressed ${exception.suppressed.contentToString()}")
    }
    val job = GlobalScope.launch(handler) {
        launch {
            try {
                delay(Long.MAX_VALUE) // Cancelled when another sibling fails with IOException
            } finally {
                throw ArithmeticException() // Second exception
            }
        }
        launch {
            delay(100)
            throw IOException() // First exception
        }
        delay(Long.MAX_VALUE)
    }
    job.join()
}

Output:

CoroutineExceptionHandler got java.io.IOException with suppressed [java.lang.ArithmeticException]

Cancellation exceptions are transparent and unwrapped by default:

val handler = CoroutineExceptionHandler { _, exception ->
    println("CoroutineExceptionHandler got $exception")
}
val job = GlobalScope.launch(handler) {
    val inner = launch { // All coroutines in this stack will be cancelled
        launch {
            launch {
                throw IOException() // Original exception
            }
        }
    }
    try {
        inner.join()
    } catch (e: CancellationException) {
        println("Rethrowing CancellationException with original cause")
        throw e // Cancellation exception rethrown, but original IOException is handled
    }
}
job.join()

Output:

Rethrowing CancellationException with original cause
CoroutineExceptionHandler got java.io.IOException

Supervision

As we've seen, cancellation propagates bidirectionally through the coroutine hierarchy. Now, let's consider scenarios requiring one-way cancellation.

A good example is a UI component that defines jobs within its scope. If any child job fails, it's not always necessary to cancel (effectively kill) the entire UI component, but if the UI component is destroyed (and its job cancelled), all child jobs should fail because their results are no longer needed.

Another example is a service process that spawns child jobs and needs to supervise their execution, tracking failures and restarting them when they fail.

SupervisorJob

SupervisorJob can be used for these purposes. It's similar to a regular Job, with the key difference that cancellation of a SupervisorJob propagates only downward. This is easy to observe in an example:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val supervisor = SupervisorJob()
    with(CoroutineScope(coroutineContext + supervisor)) {
        // Launch first child—this example ignores its exception (don't do this in practice!)
        val firstChild = launch(CoroutineExceptionHandler { _, _ -> }) {
            println("First child is failing")
            throw AssertionError("First child is cancelled")
        }
        // Launch second child
        val secondChild = launch {
            firstChild.join()
            // First child cancelled without propagating to second child
            println("First child is cancelled: ${firstChild.isCancelled}, but second one is still active")
            try {
                delay(Long.MAX_VALUE)
            } finally {
                // Cancellation propagates from supervisor
                println("Second child is cancelled because supervisor is cancelled")
            }
        }
        // Wait until first child fails and completes
        firstChild.join()
        println("Cancelling supervisor")
        supervisor.cancel()
        secondChild.join()
    }
}

Output:

First child is failing
First child is cancelled: true, but second one is still active
Cancelling supervisor
Second child is cancelled because supervisor is cancelled

SupervisorScope

For scoped concurrency, supervisorScope can replace coroutineScope to achieve the same goal. It propagates cancellation only one-way and cancels all child jobs when the job itself fails. The job itself waits for all children to complete, just like coroutineScope.

Example:

import kotlin.coroutines.*
import kotlinx.coroutines.*

fun main() = runBlocking {
    try {
        supervisorScope {
            val child = launch {
                try {
                    println("Child is sleeping")
                    delay(Long.MAX_VALUE)
                } finally {
                    println("Child is cancelled")
                }
            }
            // Use yield to give child a chance to execute print
            yield()
            println("Throwing exception from scope")
            throw AssertionError()
        }
    } catch (e: AssertionError) {
        println("Caught assertion error")
    }
}

Output:

Child is sleeping
Throwing exception from scope
Child is cancelled
Caught assertion error

Exceptions in Supervised Coroutines

Another key difference between regular and supervised jobs is exception handling. Each child job in a supervised coroutine should handle its own exceptions through exception handling mechanisms. This difference arises because a child's failure does not propagate to its parent. This means that coroutines launched directly inside a supervisorScope do use the CoroutineExceptionHandler set in their scope, similar to the parent coroutine (see the CoroutineExceptionHandler section for more details).

Example:

import kotlin.coroutines.*
import kotlinx.coroutines.*

fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception ->
        println("CoroutineExceptionHandler got $exception")
    }
    supervisorScope {
        val child = launch(handler) {
            println("Child throws an exception")
            throw AssertionError()
        }
        println("Scope is completing")
    }
    println("Scope is completed")
}

Output:

Scope is completing
Child throws an exception
CoroutineExceptionHandler got java.lang.AssertionError
Scope is completed
Tags: kotlin

Related Articles

Designing Alertmanager Templates for Prometheus Notifications

How to craft Alertmanager templates to format alert messages, improving clarity and presentation. Alertmanager uses Go’s text/template engine with additional helper functions. Alerting rules referenc...

Deploying a Maven Web Application to Tomcat 9 Using the Tomcat Manager

Tomcat 9 does not provide a dedicated Maven plugin. The Tomcat Manager interface, however, is backward-compatible, so the Tomcat 7 Maven Plugin can be used to deploy to Tomcat 9. This guide shows two...

Skipping Errors in MySQL Asynchronous Replication

When a replica halts because the SQL thread encounters an error, you can resume replication by skipping the problematic event(s). Two common approaches are available. Methods to Skip Errors 1) Skip a...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.