Coroutines Exceptions Handling: A Guide to SupervisorScope, SupervisorJob, and CoroutineExceptionHandler.

Coroutines Exceptions Handling: A Guide to SupervisorScope, SupervisorJob, and CoroutineExceptionHandler.

If you are a seasoned Android developer with Kotlin then you have worked and will continue to use Coroutines for asynchronous and multithreading operations since they are lightweight, easy to use, and, most importantly, constantly under maintenance to make sure they're performing to their optimum. But are we really handling its exceptions correctly, especially where we have coroutine hierarchies—in other words, root, parent, and child coroutines. Let's debunk this

Coroutine failure

Yes! Coroutines fail, and we may not have experienced an instance where our coroutines failed us but keep in mind that coroutines fail and this can lead to a crash in our applications which can be fatal to the user experience. You don't want your app uninstalled because you didn't catch an exception earlier.

Coroutine exception propagation

When a coroutine fails mild execution because of an exception, it is immediately canceled and this exception is propagated to the parent coroutine, which is also canceled together with all its children in the hierarchy, and finally to the root of the coroutine.

Enough words; illustrate in code

fun main(){
    val rootJob = runBlocking {

        val parentJob = launch {

            println("Coroutine is active")

            val childJob1 = launch {

                println("Child job 1 : child job 1")
            }
            val childJob2 = launch {

                println("Child job 2 : child job 2")
            }
            launch() {

                println("rooting : parent job")
            }

        }

        launch() {
            println("run blocking main coroutine job ")
        }

    }
}

When an exception occurs in a coroutine the exception is propagated upwards in the hierarchy causing the cancellation of the parent , the root and finally its children. Let's first understand the execution hierarchy of a coroutine and a coroutine lifecycle before we introduce an exception and monitor the propagation.

In the above code, we have the runBlocking that helps us start coroutines in the main function, and inside it we have a job named parentJob which is cancellable and inside it we have started child coroutines namely childJob1, childJob2 and unnamed coroutine and because it has not been assigned a job it automatically falls under parentJob as its handler then we have another unnamed coroutine started and launched within runBlocking context and handled by the rootJob for cancellation purposes.

Below is the output in the order of execution

Coroutine is active

run blocking main coroutine job

Child job 1 : child job 1

Child job 2 : child job 2

rooting : parent job

runBlock executes sequentially but before the parentJob could execute/launch its children runBlocking launches its unnamed coroutine. and to better understand this lets delay the runBlocking coroutine job for two seconds and observe the output.

  launch() {
            delay(2000)
            println("run blocking main coroutine job ")
        }

This is the output.

Coroutine is active

Child job 1 : child job 1

Child job 2 : child job 2

rooting : parent job

run blocking main coroutine job

So what do we understand from this? In the first occurrence, the unnamed block of runBlocking is launched first together with the parentJob and suspended till ready and the parentJob is ready first but before it could launch its children the unnamed job is ready and the output is printed, the parentJob children are now ready behind runBlocking task but in the second occurrence the runBlocking unnamed job takes time to execute and so its suspended till its ready and in the meantime the parentJob is ready with its children and so they are printed.

Having understood the execution hierarchy, let's introduce an exception in both of these instances.

fun main() {
    val rootJob = runBlocking {

        val parentJob = launch {

            println("Coroutine is active")

            val childJob1 = launch {
                throw Exception("From child 1")
            }
            val childJob2 = launch {
                println("Child job 2 : child job 2")
            }
            launch() {
                println("rooting : parent job")
            }

        }

        launch() {
            delay(2000)
            println("run blocking main coroutine job ")
        }
    }
}

If we run the code above, we get the below output:

Coroutine is active

Exception in thread "main" java.lang.Exception: From child 1

The exception in childJob1 is propagated to the parent canceling the parent and hence canceling all its children and so childJob2 is never launched and the same is propagated to the root job which is the run blocking and so the unnamed job is cancelled in the inCompleting stage and so it never completes its task that's why "run blocking coroutine job" is never printed but if we didnt delay for 5 seconds it would get to finish its task before the run blocking is canceled with the below output.

Coroutine is active

run blocking main coroutine job

Exception in thread "main" java.lang.Exception: From child 1

The exception is propagated to the parent which in turn cancels all its children and this is a phenomenon that could lead to our application crushing and so how do we mitigate this?

Introducing Supervisor job and Supervisor Scope

SupervisorJob

A SupervisorJob behaves differently from a normal job in a way that it stops the propagation of the exceptions to the other children and so the cancellation of a child does not affect other children and It won't cancel itself.

Let's use a SupervisorJob in our example above.

import kotlinx.coroutines.*

fun main() {

val scope = CoroutineScope(SupervisorJob())

    runBlocking {

        launch {

            println("Coroutine is active")

            val childJob1 = scope.launch {
                throw Exception("From child 1")
            }
            val childJob2 = scope.launch {
                println("Child job 2")
            }
            launch() {
                println("parent job scope")
            }

        }

        launch() {
            println("run blocking scope")
        }
    }
}

This is the output to the code above

Coroutine is active

Child job 2

run blocking scope

parent job scope

The childJob2 has not been cancelled by the exception from childJob1 because of the help of a SupervisorJob making every child independent of the other stopping propagation of exceptions but there are somethings you should keep in mind when using SupervsorJob for it to be effective.

  • SupervisorJob only propergates exceptions to its direct children.

  • Use SupervisorJob where we have children coroutines and want to isolate exceptions independently.

  • Dont use SupervisorJob as an argument to a parent since this will only treat it as a normal job and so it won't stop the propagation.

Supervisor Scope

SupervisorScope can also be used to avoid the behaviour of exception propagation to other children when one child gets canceled, and this is done by supervising the children under a Supervisor Scope by wrapping coroutine builders inside a Supervisor Scope.

Lets use a Supervisor Scope in our example

import kotlinx.coroutines.*

fun main() {

    runBlocking {

        supervisorScope {

            println("Coroutine is active")

            val childJob1 = launch {
                throw Exception("From child 1")
            }
            val childJob2 = launch {
                println("Child job 2")
            }
            launch() {
                println("parent job scope")
            }

        }

        launch() {
            println("run blocking scope")
        }
    }
}

This is the output

Coroutine is active

Exception in thread "main" java.lang.RuntimeException: Suppressed: java.lang.Exception: From child 1

Child job 2

parent job scope

run blocking scope

When to Use SupervisorJob and When to Use SupervisorScope

Use SupervisorJob when you don't want an exception from a child to cancel the parent or the other children, because if a parent fails, then all its children are cancelled.

Supervisor Scope is only used as a parent to run children coroutines, and so you can use it as a builder to wrap children coroutines to avoid cancellation of children coroutines if one child is cancelled due to an exception thrown.

Coroutine Handler

A Coroutine handler is a very important component as it gives us more information about a coroutine exception like when the exception occurred, where the exception occurred, and its origin and we can define what should happen when an exception occurs, but keep in mind that it only works with launch and not async and it does not stop the propagation of exceptions and so it is encouraged t use it with Supervisor Job.

Lets implement it in our example

import kotlinx.coroutines.*

fun main() {

    val handler = CoroutineExceptionHandler { ctx, exception ->
        println("Caught $exception")
    }

    val scope = CoroutineScope(SupervisorJob() + handler)

    runBlocking {

        launch {

            println("Coroutine is active")

            val childJob1 = scope.launch {
                throw Exception("From child 1")
            }
            val childJob2 = scope.launch {
                println("Child job 2")
            }
            launch() {
                println("parent job scope")
            }

        }

        launch() {
            println("run blocking scope")
        }
    }
}

This is the output

Coroutine is active

Caught java.lang.Exception: From child 1

Child job 2

run blocking scope

parent job scope

The coroutine exception handler can be useful, especially when debugging. It helps in logging and monitoring exceptions, giving us an easy time tracking down and diagnosing exceptions.

I hope you have learned something about handling exception propagation in your coroutines, and now you are in a position to deal with them gracefully to avoid unnecessary crushing of your applications.

Happy coding.