Exception handling in Coroutine

coroutine-exception-handling

Let’s talk about handling errors in coroutines. This is key to building reliable apps. We’ll cover the basics and show you how to deal with problems effectively.

Concept of a “Job” in Kotlin Coroutines

When you start a coroutine with launch or async, you get a Job object. A Job lets you control the coroutine’s lifecycle, including canceling it. Jobs can also be organized in parent-child relationships, so you can manage groups of coroutines together.

Job states

coroutine-job-state

A Job manages a coroutine’s lifecycle and goes through different states. We can’t directly access these states, but we can check properties like isActive, isCompleted, and isCancelled.

  • When all of a Job‘s child coroutines finish, isCompleted becomes true, and isActive and isCancelled become false.
  • If a coroutine fails or you call cancel(), the Job moves to a “Cancelling” state, and then to a “Cancelled” state. In the “Cancelled” state, isCompleted and isCancelled are true, and isActive is false.

What happens when an exception occurs in a job?

In a parent-child coroutine hierarchy, canceling a parent also cancels all its children. Also, if a child coroutine throws an exception (other than a CancellationException), the parent and all its other children are also canceled.

For example:

runBlocking {
 launch {
   println("First Job")
 }

 launch {
    delay(2_000)
    println("Second Job")
 }

  delay(1_000)
  throw IllegalArgumentException()
}

In this example, after one second, the parent coroutine will throw an exception, which immediately cancels all its children, including the second job.

So, what occurs when we encounter an exception other than CancellationException from the children?

runBlocking {
  launch {
   println("First Job")
   throw IllegalArgumentException()
  }

  launch {
   delay(2_000)
   println("Second Job")
  }
}

Here, if firstJob throws an IllegalArgumentException, secondJob will be immediately canceled because the parent job is canceled.

Supervision

SupervisorJob

With a SupervisorJob, if one child fails, it doesn’t cancel the parent or other children. The SupervisorJob doesn’t propagate the exception, so it and the other children can keep running.

runBlocking {
val scope = CoroutineScope(SupervisorJob())

val firstJob = scope.launch {
delay(1_000)
println("firstJob")
throw IllegalArgumentException()
}
val secondJob = scope.launch {
delay(2_000)
println("secondJob")
}

secondJob.join()
println("Is secondJob cancelled: ${secondJob.isCancelled}")
println("Is firstJob is cancelled: ${firstJob.isCancelled}")
}

Output:

firstJob

Exception in thread "DefaultDispatcher-worker-2 @coroutine#2" java.lang.IllegalArgumentException
at com.kolsasia.kol.ExampleUnitTest$addition_isCorrect$1$firstJob$1.invokeSuspend(ExampleUnitTest.kt:28)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:570)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:750)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:677)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:664)
Suppressed: kotlinx.coroutines.DiagnosticCoroutineContextException: [CoroutineId(2), "coroutine#2":StandaloneCoroutine{Cancelling}@34c9acd3, Dispatchers.Default]
secondJob

Is secondJob cancelled: false
Is firstJob is cancelled: true

Here, we’re using a SupervisorJob for our CoroutineScope. After one second, firstJob will throw an exception and be canceled, but this exception won’t be passed up to the SupervisorJob. So, secondJob will complete normally without being canceled.

Let see another example

runBlocking {

    val firstJob = launch (SupervisorJob()) {
        delay(1_000)
        println("firstJob")
        throw IllegalArgumentException()
    }
    val secondJob = launch {
        delay(2_000)
        println("secondJob")
    }

    secondJob.join()
    println("Is secondJob cancelled: ${secondJob.isCancelled}")
    println("Is firstJob is cancelled: ${firstJob.isCancelled}")
}

The result is the same as the previous example. Because firstJob uses a SupervisorJob, its failure doesn’t affect the parent or secondJob.

SupervisorScope

You can also create a scope with supervisorScope. This builder creates a CoroutineScope with a SupervisorJob. Unlike coroutineScope (which uses a regular Job), if a child coroutine fails within a supervisorScope, it won’t affect the other children or the scope itself.

For example:

runBlocking {
    supervisorScope {
        val firstJob = launch {
        delay(1_000)
        println("firstJob")
        throw IllegalArgumentException()
    }

    val secondJob = launch {
        delay(2_000)
        println("secondJob")
    }

        secondJob.join()
        println("Is secondJob cancelled: ${secondJob.isCancelled}")
        println("Is firstJob is cancelled: ${firstJob.isCancelled}")
    }
}

The output

firstJob
Exception in thread "Test worker @coroutine#2" java.lang.IllegalArgumentException
secondJob
Is secondJob cancelled: false
Is firstJob is cancelled: true

Exception handling strategies

When using “launch”

On the launch , exceptions will be thrown directly so you just use try/catch inside of the block:

runBlocking {
    val firstJob = launch {
        try {
            delay(1_000)
            println("firstJob")
            throw IllegalArgumentException()
        } catch (e: Exception) {
            println("Exception caught: $e")
        }
    }

    firstJob.join()
    println("Is firstJob cancelled: ${firstJob.isCancelled}")
}

Output:

firstJob
Exception caught: java.lang.IllegalArgumentException
Is firstJob cancelled: false

Also you can use the CoroutineExceptionHandler which is similar to Thread.uncaughtExceptionHandler.

val exceptionHandler = CoroutineExceptionHandler {_, exception ->
    println("CoroutineExceptionHandler caught $exception")
}

runBlocking {
    val scope = CoroutineScope(exceptionHandler)

    val firstJob = scope.launch {
        delay(1_000)
        println("firstJob")
        throw IllegalArgumentException()
    }
    firstJob.join()
    println("Is firstJob cancelled: ${firstJob.isCancelled}")
}

Output:

firstJob
CoroutineExceptionHandler caught java.lang.IllegalArgumentException
Is firstJob cancelled: true

Here, the CoroutineExceptionHandler will catch any uncaught exceptions. Unlike the previous example, the first job is canceled in this case, so firstJob.isCancelled will be true.

When using “async”

On the async, exceptions will not be thrown until getting the result by calling the Deferred.await() . You can use try/catch while getting result:

runBlocking {
    val scope = CoroutineScope(Dispatchers.Default)
    val deferred = scope.async {
        println("firstJob")
        throw IllegalArgumentException()
    }

    try {
        deferred.await()
    } catch (e: Exception) {
        println("Exception caught $e")
    }
}

Output:

firstJob
Exception caught java.lang.IllegalArgumentException

CoroutineExceptionHandler

CoroutineExceptionHandler is a last-resort mechanism for global “catch all” behavior. You cannot recover from the exception in the CoroutineExceptionHandler. The coroutine had already completed with the corresponding exception when the handler is called. Normally, the handler is used to log the exception, show some kind of error message, terminate, and/or restart the application.

It’s important to know that async always catches exceptions and stores them in the Deferred object. Using a CoroutineExceptionHandler with async has no effect.

For example:

runBlocking {
    val scope = CoroutineScope(exceptionHandler)
    val firstJob = scope.async {
        println("firstJob")
        throw IllegalArgumentException()
    }
    firstJob.await()
}

In this case, CoroutineExceptionHandler remains ineffective and also exception won’t be propagated until we invoke Deferred.await() .

Conclusion

This article showed you the basics of handling coroutine exceptions:

  • Use try/catch for simple, local error handling.
  • Use a CoroutineExceptionHandler on the parent coroutine for centralized exception handling.
  • Use SupervisorJob and supervisorScope to prevent sibling coroutines from being canceled due to a failure in one of them.

We hope this helps!

Leave a Reply

Your email address will not be published. Required fields are marked *