Oliver.O
10/09/2023, 5:36 PMasync
call's exception, why does it re-throw in the surrounding coroutine scope? Details in 🧵import kotlinx.coroutines.* // ktlint-disable no-wildcard-imports
private class TestException(override val message: String) : Throwable()
fun main(): Unit = runBlocking {
suspend fun reportCatching(name: String, block: suspend () -> Unit) {
try {
block()
println("$name: block finished without exception")
} catch (throwable: Throwable) {
println("$name: block threw $throwable")
throwable.cause?.let { println(" cause: ${throwable.cause}") }
}
}
reportCatching("outer") {
coroutineScope {
reportCatching("inner, wrapped in coroutineScope") {
coroutineScope {
async {
throw TestException("boom 1")
}.await()
}
}
reportCatching("inner, bare") {
async {
throw TestException("boom 2")
}.await()
}
}
}
}
produces
inner, wrapped in coroutineScope: block threw TestException: boom 1
inner, bare: block threw TestException: boom 2
outer: block threw TestException: boom 2
Try it in playground.
Why so?Jacob
10/09/2023, 6:06 PMOliver.O
10/09/2023, 6:09 PMasync
user (due to architectural choices) and stumbled across this by accident: Does it make sense to signal that exception twice?Jacob
10/09/2023, 6:12 PMit cancels the parent job (or outer scope) on failure to enforce structured concurrency paradigmMakes sense to me. It cancels the parent scope and the parent scope yields the exception that caused it to fail
Oliver.O
10/09/2023, 6:18 PMreportCatching
, but, obviously, the exception handling mechanism cannot deal with two active exceptions in parallel).louiscad
10/11/2023, 12:50 PMcoroutineScope { … }
. That is structured concurrency.Oliver.O
10/11/2023, 1:03 PMThe result of the deferred is available when it is completed and can be retrieved by await method, which throws an exception if the deferred had failed.A
coroutineScope
could contain multiple await
calls, and I might want to find out which one produced an exception.louiscad
10/11/2023, 1:03 PMOliver.O
10/11/2023, 1:39 PMCoroutine builders come in two flavors: propagating exceptions automatically (launch and actor) or exposing them to users (async and produce).And the example below does catch an exception from an
async
builder directly, so doing so seems perfectly legitimate.louiscad
10/11/2023, 1:41 PMsupervisorScope { }
Oliver.O
10/11/2023, 1:43 PMtry {
deferred.await()
println("Unreached")
} catch (e: ArithmeticException) {
println("Caught ArithmeticException")
}
louiscad
10/11/2023, 1:44 PMOliver.O
10/11/2023, 1:48 PMJacob
10/11/2023, 1:49 PMOliver.O
10/11/2023, 8:54 PMawait
invocation can be caught directly, as shown in the example. (Of course, in the context of structured concurrency, it would then be the caller's responsibility to either re-throw the exception, cancel the parent's remaining children, or proceed otherwise in a sane way.)
Always appreciate more evidence.Jacob
10/11/2023, 9:07 PMOliver.O
10/12/2023, 11:33 AMDeferred
The result of the deferred is available when it is completed and can be retrieved by await method, which throws an exception if the deferred had failed.suggest that there is an exception which might be caught, but doing so would apparently contradict the section on exception propagation (emphasis mine):
If a coroutine encounters an exception other thanI have actually read both multiple times over the years, but obviously it needed an actual example case to show that this mode of propagation completely escapes the try/catch mechanism. IMO the problem is not merely the documentation, but rather an API design too complicated to memorize. Having a special behavior for "root" coroutines seems more like an implementation artifact than a concept serving an actual use case. And while catching an exception directly from a, it cancels its parent with that exception. This behaviour cannot be overriddenCancellationException
launch
or async
call doesn't make much sense, catching an exception from await
does. Changing the documentation to something like "You may not catch an exception from an async
call, you should catch it from its parent coroutine instead" is possible, but would still indicate a seemingly arbitrary deviation from general exception handling, relevant only for async
. These complications may not surface in other scenarios (again, I rarely use async
), as we normally don't care how exactly exceptions propagate, and the default cancellation behavior is just fine.
Maybe I should open another issue to at least clarify things or come up with an even better design. Thanks everyone for contributing, much appreciated!Jacob
10/12/2023, 2:30 PMHaving a special behavior for “root” coroutines seems more like an implementation artifact than a concept serving an actual use case.launching root coroutines is supposed to be rare and is likely only done in order to change the failure semantics. I don’t think there’s a lack of use cases for making such a change.
And while catching an exception directly from aSure it does. ie if using nulls to represent failures:orlaunch
call doesn’t make much sense,async
async{try{executeSomeExceptionThrowingCode()}catch(ex: Throwable){null}}
but obviously you can return some sort of result object that has more details about the failure https://elizarov.medium.com/kotlin-and-exceptions-8062f589d07Oliver.O
10/12/2023, 2:40 PMasync
call. I was referring to try { async { ... } } catch (...) { ... }
, which doesn't make much sense. Or did I miss something?Jacob
10/12/2023, 2:41 PM