Kotlin Coroutines framework allows you to write concurrent code that looks like a standard sequential code. However, in some cases, it can be challenging to get this code to work according to your requirements. In this article, I’ll describe what happens when coroutines fail and explain how to use SupervisorJob
and CoroutineExceptionHandler
to handle these failures.
Uncaught Exceptions in Coroutines
Consider the following code which launches two coroutines in the same CoroutineScope
:
fun test() { val scope = CoroutineScope(Dispatchers.Default) repeat(2) { i -> scope.launch { val result = doWork(i) Log.i("Test", "work result: ${result.toString()}"); } } } private suspend fun doWork(iteration: Int): Int { Log.i("Test", "working on iteration: $iteration"); return if (iteration == 0) { throw WorkException("work failed") } else { delay(50) iteration * 10 } } private class WorkException(msg: String): RuntimeException(msg)
Note that the call to doWork()
in the first coroutine throws an exception, while the same call in the second coroutine returns a valid result after a short delay.
If I execute this code, the app crashes and the logcat shows the following messages:
working on iteration: 1 working on iteration: 0 FATAL EXCEPTION: DefaultDispatcher-worker-1 [...] $WorkException: work failed
Looks like the exception thrown from the first coroutine crashed the application, and neither of these coroutines printed its result.
In most cases, this is the desired behavior. If you want to avoid these crashes, the right thing to do is to handle the exceptions inside the coroutines and prevent their propagation to the CoroutineScope
. However, there are cases when active coroutines inside the same CroutineScope
execute tasks which can fail by design and you’ll want to handle these failures gracefully, in one place.
To enable “graceful” handling of coroutines failures, our design must have the following characteristics:
- The application doesn’t crash even if a coroutine throws an uncaught exception.
- When a coroutine throws an uncaught exception, it doesn’t affect the execution of other coroutines in the same scope.
In the next sections of this post, I’ll show you how to achieve these characteristics using SupervisorJob
and CoroutineExceptionHandler
objects.
Prevent Crashes When Coroutines Throw Uncaught Exceptions
To avoid the crashes, I can install a custom CoroutineExceptionHandler
into my CoroutineScope:
private val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable -> if (throwable is WorkException) { Log.i("ExceptionHandler", "ignoring exception: ${throwable.javaClass.simpleName}") } else { Thread.getDefaultUncaughtExceptionHandler()?.uncaughtException(Thread.currentThread(), throwable) } } fun testWithExceptionHandler() { val scope = CoroutineScope(Dispatchers.Default + exceptionHandler) repeat(2) { i -> scope.launch { val result = doWork(i) Log.i("Test", "work result: ${result.toString()}"); } } }
Executing this variant of the test function will produce the following output in the logcat:
working on iteration: 1 working on iteration: 0 ignoring exception: WorkException
This means that we successfully intercepted an uncaught exception of type WorkException
and prevented the crash. If you want to handle these exceptions differently, then just modify the logic inside CoroutineExceptionHandler
.
Unfortunately, neither of our coroutines printed its result, so we still have more work to do.
Prevent Sibling Coroutines Cancellation On Uncaught Exceptions
The failure in the first coroutine doesn’t crash the application anymore, but we still don’t get any results, even though the second coroutine doesn’t fail. The reason is that CoroutineScope
automatically cancels all its child coroutines when either of them fails.
To change the default behavior of our CoroutineScope
and prevent sibling coroutines’ cancellation, we can install an instance of SupervisorJob
into it:
fun testWithSupervisorJob() { val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) repeat(2) { i -> scope.launch { val result = doWork(i) Log.i("Test", "work result: ${result.toString()}"); } } }
The output in the logcat becomes:
working on iteration: 0 working on iteration: 1 FATAL EXCEPTION: DefaultDispatcher-worker-1 [...] $WorkException: work failed work result: 10
Now the second coroutine prints its results, just like we wanted, but the application crashes again. This demonstrates that SupervisorJob
prevents sibling coroutines’ cancellation, but it doesn’t stop the uncaught exception from propagating all the way up to the default exception handler (that’s the component that crashes the app).
Prevent Crashes And Sibling Coroutines Cancellation On Uncaught Exceptions
The obvious conclusion from the previous two sections is that we need to install both the SupervisorJob
and the CoroutineExceptionHandler
objects into our CoroutineScope
:
fun testWithSupervisorJobAndExceptionHandler() { val scope = CoroutineScope(Dispatchers.Default + SupervisorJob() + exceptionHandler) repeat(2) { i -> scope.launch { val result = doWork(i) Log.i("Test", "work result: ${result.toString()}"); } } }
This configuration will yield the following logcat output:
working on iteration: 0 working on iteration: 1 ignoring exception: WorkException work result: 10
And now, finally, we achieved the desired behavior: the application doesn’t crash and the second coroutine successfully reports its result.
Kotlin Coroutines in Android Course
Master the most advanced concurrency framework for Android development.
Go to CourseConclusion
Kotlin Coroutines can be very tricky to use, especially when you have non-trivial requirements. In this article, I demonstrated how to use SupervisorJob
and CoroutineExceptionHandler
to prevent application crashes and siblings’ cancellation when coroutines throw uncaught exceptions.
It seems to me that coroutines are a great way to write (and specially read) asynchronous code, but the complexities of error handling in structured concurrency are its achilles heel. It really is a mess – or maybe I just have not found a clear explanation of it yet.
Hi Vasily, I like very much the article, really. I have only one comment: “by overriding the parent of async with a standalone SupervisorJob, I broke “structured concurrency” cancellation mechanics.” I don’t think that you’re breaking anything here, you’re just using the cancellation definition for Job and SupervisorJob. I mean, you don’t break anything if it works as is intended to work, and this is the case: the SupervisorJob cancellation mechanics are the ones that you’re experiencing by aplying SupervisorJob, no others, so you’re not breaking anything.
Congrats for the article!
Hey Julian,
I see what you mean. You’re basically saying that if it works as expected, I shouldn’t call it “a break”. However, in this case, I think it’s appropriate.
Coroutines provide support for structured concurrency, but these are still two different concepts. Therefore, you can use coroutines without structured concurrency (though it requires more work). In this case, by breaking the chain of Jobs, I indeed broke the predicate of structured concurrency in that area of my code. Therefore, I still think my phrasing is accurate.
Would you agree with this explanation?
Hey, thanks for your answer!
I agree with this explanation, I mean, maybe I assume that speaking about structured concurrency you were speaking about Kotlin coroutines, but that’s not the case. Thanks again!
I replace async(SupervisorJob()) with async(Job()) and still get the same behaviour of crash getting handled . Can anyone explain ?
Job() creates an instance, which is not linked to the rest of the jobs tree. Which means that there is nothing to propagate an error to. Job(coroutineContext[Job]) is the “proper” way.