Instructions
Coroutines are asynchronous. That means that they may execute in a different context than the caller. So coroutine builders like launch or async return immediately and don’t propagate exceptions to the caller. Their invocations don’t throw exceptions, so wrapping them in try-catch blocks doesn’t make sense.
On the other hand, the runBlocking builder is synchronous from the caller’s perspective. It blocks the calling thread until the coroutine finishes and optionally throws the exceptions thrown by the coroutine. So, to handle exceptions thrown by the coroutine inside the runBlocking block, you can wrap it in a try-catch block.
You can use the try-catch blocks, or other Kotlin constructs like runCatching normally in coroutines or suspending functions to recover from the exceptions thrown by the code inside. Starting nested coroutines using the launch or async builders doesn’t change the rules of exception handling. Their invocations don’t throw exceptions, no matter if they are inside the coroutine or not.
There’s one important thing to remember. Coroutine cancelation under the hood is based on throwing a CancellationException. So, you should either not catch CancellationExceptions or, if you’ve already caught them, you have to rethrow them. If the CancellationException is caught and not rethrown, the coroutine cancellation won’t work. The coroutine will continue to run.
What happens with the uncaught exceptions thrown inside the coroutines which aren’t in the runBlocking block?
It depends on several factors. By default, they’re propagated to the parent coroutine scope, which in turn, propagates them to their parent scope and so on until the exceptions reach the root scope. Also, by default, if the parent coroutine receives an exception from its child, it cancels all its children and then itself.
The root scope is either the very first scope in the hierarchy or the scope having the SupervisorJob as its job. You can create a first scope by using the CoroutineScope or MainScope factory functions. Or you can rely on Android-based scopes, such as the viewModelScope.
Coroutine Exception Handlers
The root scope can have the CoroutineExceptionHandler added to its CoroutineContext. It can look like this:
CoroutineScope(
SupervisorJob() + CoroutineExceptionHandler { _, throwable ->
Log.e("Lesson5", "Exception handler", throwable)
}
)
The coroutine exception handler is a last resort for exceptions that haven’t been caught so far. You can’t recover from the exceptions using the exception handler. You can only use it for diagnostic purposes.
For example, you can log the exception to logcat or send it to a crash reporting system like Crashlytics. If you rethrow the exception from the handler, it will behave like an uncaught exception thrown outside the coroutine. The exception will be propagated to the uncaught exception handler of the thread and by default, it will crash the application.
Note that installing the uncaught exception handler in the non-root scopes has no effect. The exceptions from the coroutines in the non-root scopes will be propagated to the parent scope despite the exception handler installed.
Not having the uncaught exception handler in the root scope doesn’t mean that the exceptions are swallowed. The uncaught exception handler is optional. If there’s no handler, the exceptions are propagated to the uncaught exception handler of the thread.
There is an important anomaly in the exception handling in coroutines. The exceptions thrown from the coroutines of the async builders in the root scopes aren’t propagated to the uncaught exception handlers. You saw the example of such a case in the previous lesson. Take a look at a more complex one:
val scope = CoroutineScope(CoroutineExceptionHandler { _, throwable ->
Log.e("Lesson5", "Exception handler", throwable)
})
scope.async { throw RuntimeException("Test") }
The exception thrown from the async coroutine will never reach the uncaught exception handler. That’s because
the async is in the root scope.
Note that such exceptions aren’t just completely swallowed. Apart from not reaching the uncaught exception handler, all the other rules apply normally. For example, such exceptions will still cause the failure of the parent coroutine and all other siblings, unless there’s a SupervisorJob in the parent context.
It doesn’t mean that you can’t handle the exceptions from the async coroutines in root scopes. The swallowing only applies to the uncaught exceptions. You can still use the await or awaitAll functions and wrap them in try-catch blocks.
Supervision
If you want to prevent the parent coroutine from being canceled when its child throws an exception, you can use a SupervisorJob. All uncaught exceptions from the children will reach the scope if it has a SupervisorJob in its context. Such uncaught exceptions won’t be propagated to the parent scope. The SupervisorJob is a root scope.
The MainScope function creates a scope with the SupervisorJob as its job. In the case of other scopes you have to add a SupervisorJob manually to the context.
One more thing to remember: the SupervisorJob in a context causes the failure of one of its children to not affect others, they can continue to run.
coroutineScope & supervisorScope
The coroutineScope is a suspending function that creates a new coroutine scope and suspends until all the children coroutines finish. Its invocation also throws all the uncaught exceptions from its lambda and all of its child coroutines.
So, any such uncaught exceptions won’t directly reach the uncaught exception handler of the scope which called the coroutineScope. They will reach that handler only if the invocation of the coroutineScope itself produces an uncaught exception.
Look at the following example:
val scope = CoroutineScope(CoroutineExceptionHandler { _, throwable ->
Log.e("Lesson5", "Exception handler", throwable) // 1
})
scope.launch {
try {
coroutineScope {
throw RuntimeException("coroutineScope went wrong") // 2
}
} catch (e: Exception) {
Log.e("Lesson5", "Caught exception", e) // 3
}
}
The exception thrown in the place marked as 2 will reach the catch block in the place marked as 3. It won’t reach the exception handler in the place marked as 1.
In the case of a coroutineScope failure in one of its children, this causes the cancelation of all the others and the parent. The difference between the coroutineScope and supervisorScope is that the supervisorScope uses a SupervisorJob as its job.
Their children can fail independently, not affecting their siblings and the parent. It implies that the uncaught exceptions from the child coroutines of the supervisorScope won’t be thrown by the supervisorScope invocation. But they’ll be propagated to the uncaught exception handler of the scope which called the supervisorScope.
The exceptions thrown directly from the supervisorScope lambda behave the same as in the coroutineScope, they are thrown by the supervisorScope invocation.
Exception Aggregation
What happens if multiple children coroutines throw exceptions at the same time? The first encountered exception wins, and the others are added as suppressed exceptions to it. Note that suppressed exceptions are a mechanism provided by the Java standard library and it isn’t specific to Kotlin Coroutines.