Error-handling with Coroutines has always been a confusing point for many developers, and that’s because of a number of reasons. Not only are there multiple ways to handle them, but the error propagation mechanism that comes with structured concurrency needs to be understood first.
And there are plenty of little situations where error-propagation with coroutines happens differently such as:
- Errors thrown in scopes with or without
CoroutineExceptionHandler
(parent and child scopes) - Errors thrown within a try-catch block
- Errors thrown in nested coroutines
- Errors thrown in a
SupervisorJob
But before we look into the different ways we can effectively handle coroutine errors, we need to understand how they work first.
Errors in Structured Concurrency
A parent coroutine job and its three children
Looking at the above graph, if an exception would be thrown by Job 3, Job 3 would be cancelled, it would propagate the exception up to Parent Job, Parent Job would then be cancelled and thus cancel Jobs 1 & 2, then Parent Job would propagate the exception up to its parent (if it has any). This will continue all the way until the root of the coroutine is reached.
IF the root job belongs to a scope with a CoroutineExceptionHandler
, the error would be handled accordingly, otherwise the exception is simply thrown and your app will probably crash.
This means that having a CoroutineExceptionHandler
anywhere but the root does absolutely nothing.
So why do coroutines act this way? Why does the failure of one child stop the entire coroutine all the way up to the root?
Why are exceptions propagated up to the root?
Well the idea is a single coroutine is meant to achieve one (potentially combined) result. If any one child couldn’t complete, it would be a waste of resources to let other children finish. Let’s use an example.
A root coroutine that has three children to get three pieces of info
getChannelsPageInfo is a root coroutine that needs info from getUser, getChannels, and getSchedule to display as a channels page. All three of these requests are mandatory for the page to display the info it needs.
If getSchedule were to fail, there’s no need to spend more resources on the other coroutines. The page cannot properly display its desired state so it falls back to an error state instead.
Using CoroutineExceptionHandler
In the above example, if any of the three children were to fail, the page should display an error state to the user. This behaviour is the same regardless which of the three children failed.
The error is then propagated up to the root, getChannelsPageInfo, and it is up to the root to handle the error. This is where CoroutineExceptionHandler
is useful.
private val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable -> displayErrorState(throwable) } private fun getChannelsPageInfo() = viewModelScope.launch(exceptionHandler) { val user = async { getUser() } val channels = async { getChannels() } val schedule = async { getSchedule() } displayPageInfo(user.await(), channels.await(), schedule.await()) }
Say for this use-case, I want my coroutines to run in parallel, hence the use of async
. The moment any of them fails, the others can happily be cancelled and the root coroutine turns to exceptionHandler
to display the error state. This behaviour should be common for if any of the child coroutines fail.
“If a root coroutine should handle errors the same way for if any of its children fail, CoroutineExceptionHandler should be used”
But what if I need specific error-handling behaviour in each of my child coroutines?
Using try-catch blocks
What if we didn’t want the page to stop loading when one child fails? What if our channels page was sectioned off so that the user and channels could still be displayed even if the schedule failed to load?
It wouldn’t be good if our exception was re-thrown by the coroutine and propagated to the root as that would cancel all our coroutine brothers and sisters.
private suspend fun getChannels(): List<Channels> = try { tvRepository.getChannels() } catch (exception: Exception) { emptyList() }
Each of our child coroutines would then look a little something like this. If tvRepository.getChannels()
where to throw an exception, it would be handled within the try-catch block. The exception isn’t thrown by the coroutine itself, thus no coroutines are cancelled.
“If an exception in a child coroutine should not stop other coroutines belonging to the same parent, or specific error-handling behaviour is required, try-catch blocks should be used”
There is another way to encapsulate errors in coroutines without cancelling its entire family, but we’ll get to that later.
Nested Coroutines inside try-catch blocks
val user = async { try { async { getUser() }.await() } catch (exception: Exception) { getBlankUser() } }
I don’t know what kind of sadistic madman you need to be to produce code like this, but I thought it’s at least worth a mention.
In the above example, the try-catch block is utterly useless here. If getUser()
were to throw, the exception would be propagated to the root and the entire coroutine would be cancelled.
This is a bit weird really. Surely you’d expect everything inside within try
to be handled by catch
. But my dear Johnny, everything isn’t as it seems in the coroutine world.
Let’s go back to the hierarchy diagram.
The diagram representing the madman code above
As you know, coroutines follow structured concurrency, and this is established through Job
‘s. Each block you see in the above diagram is a single Job
and each Job
contains info about its parent Job
.
So when the coroutine inside the try-catch block throws an exception and gets cancelled, the exception isn’t thrown by the coroutine function itself i.e. it’s not thrown by async
. Instead, the exception gets carried by Job
object associated with the coroutine and propagated to its parents. As far as the try-catch block knows, there was no error.
Why didn’t this happen in our earlier try-catch block? Look at it again.
private suspend fun getChannels(): List<Channels> = try { tvRepository.getChannels() } catch (exception: Exception) { emptyList() }
It’s not the coroutine itself that’s wrapped inside the try-catch block. There’s no launch
or async
in there, but only the contents of the coroutine. There’s no Job
object associated directly with tvRepository.getChannels()
. If an exception occurred, it would re-throw the exception and the try-catch block would detect it.
“If a coroutine started inside try-catch blocks fails, it will not be caught by the try-catch block and will proceed to propagate its error to the root”
I hope that explanation made sense. If not, well I tried :’)
CoroutineScope
private fun getChannelsPageInfo() = viewModelScope.launch(exceptionHandler) { val user = async { getUser() } val channels = try { coroutineScope { async { val channelOne = getChannelOne() val channelTwo = getChannelTwo() combineChannels(channelOne, channelTwo) } } } catch (exception: Exception) { async { createEmptyChannelInfo() } } val schedule = async { getSchedule() } displayPageInfo(user.await(), channels.await(), schedule.await()) }
When you wrap a coroutine within a coroutineScope
, exceptions thrown within the scope will be re-thrown by the coroutineScope
itself instead of being propagated up to the root.
This means you can wrap it in a try-catch block without cancelling the parent coroutine. If you really wanted to nest coroutines inside try-catch blocks? This is exactly the way to do it.
“CoroutineScope can be used to re-throw exceptions thrown by the child coroutines contained within itself”
SupervisorScope
Remember when I said that there is another way to encapsulate errors in coroutines without cancelling the entire family? This is it.
Encapsulation of errors if getChannels was in a SupervisorScope
Using SupervisorScope
is almost like starting a sub-coroutine. This makes getChannels into a SupervisorJob
, and if any of its children would fail, the error would be propagated to getChannels but no further than that. It is then up to getChannels to handle it, either through the use of its own CoroutineExceptionHandler
(in the case of launch
), or handling via the result of Deferred.await()
(in the case of async
).
private fun getChannelsPageInfo() = viewModelScope.launch(exceptionHandler) { val user = async { getUser() } val channels = supervisorScope { async { val channelOne = getChannelOne() val channelTwo = getChannelTwo() combineChannels(channelOne, channelTwo) } } val schedule = async { getSchedule() } val channelInfo = try { channels.await() } catch (exception: Exception) { createEmptyChannelInfo() } displayPageInfo(user.await(), channelInfo, schedule.await()) }
Take the above code for example. This reflects the above diagram of our getChannels coroutine being a SupervisorJob
. If getChannelOne()
or getChannelTwo()
were to fail, the exception would now belong within channels.await()
, and it is now up to us to handle that.
This behaviour is only achievable because we used supervisorScope
. The error is only propagated up to it. If you replace it with coroutineScope
, the exception would immediately be propagated all the way up to the root.
supervisorScope { launch(channelsExceptionHandler) { val channelOne = getChannelOne() val channelTwo = getChannelTwo() combineChannelsAndPost(channelOne, channelTwo) } }
Here’s an example using launch
. In this case, it would be better to use a CoroutineExceptionHandler
.
If you don’t use one, or you don’t handle the exception thrown by your await()
, the error would be propagated up to the parent as normal.
“SupervisorScopes should be used when you have children whose errors should propagate only up to a certain point and not all the way to the root”
Conclusion
For the TLDR chaps out there:
- In a coroutine structure of parent and children, exceptions are propagated all the way up to the root
- Use
CoroutineExceptionHandler
when you want a single way to handle failing children coroutines - Use try-catch blocks inside children coroutines when you need more specific error-handling without having to cancel the parent and sibling coroutines
- Coroutines started inside try-catch blocks render the try-catch blocks useless, as the exception isn’t thrown by the coroutine itself but instead propagated to the parent
coroutineScope
can be used to re-throw the exceptions of coroutines started inside of itsupervisorScope
can be used when children should propagate errors only up to a certain point and not to the root of the entire coroutine
As you can see, the best solution for error-handling coroutines is completely situational. There is no one-answer-fits-all here.
All these interactions with structured concurrency and the availability of all these different options is why error handling coroutines is a complex and daunting topic for most, but when mastered, becomes a very powerful toolbelt for achieving an error-handling setup for pretty much any situation.
I hope you gained something from this article. Let me know down in the comments if you did!
Over the past couple of weeks, I’ve been more active on my socials than I ever have before, so I’d appreciate a follow! You might just find content you’ll enjoy as a fellow coder, on Instagram especially. The links to all of them are on the sidebar.
I hope you have a good week and as always, happy coding ༼ つ ◕_◕ ༽つ