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
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, andisActive
andisCancelled
become false. - If a coroutine fails or you call
cancel()
, theJob
moves to a “Cancelling” state, and then to a “Cancelled” state. In the “Cancelled” state,isCompleted
andisCancelled
are true, andisActive
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
andsupervisorScope
to prevent sibling coroutines from being canceled due to a failure in one of them.
We hope this helps!
Leave a Reply