https://kotlinlang.org logo
#coroutines
Title
# coroutines
o

Oliver.O

10/09/2023, 5:36 PM
After catching an
async
call's exception, why does it re-throw in the surrounding coroutine scope? Details in 🧵
This
Copy code
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
Copy code
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?
j

Jacob

10/09/2023, 6:06 PM
you’re not catching the async call’s exception. You’re only catching the await call’s exception
o

Oliver.O

10/09/2023, 6:09 PM
Good point. As I'm not a frequent
async
user (due to architectural choices) and stumbled across this by accident: Does it make sense to signal that exception twice?
j

Jacob

10/09/2023, 6:12 PM
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/async.html
it cancels the parent job (or outer scope) on failure to enforce structured concurrency paradigm
Makes sense to me. It cancels the parent scope and the parent scope yields the exception that caused it to fail
So when you catch that exception out of the await, you’re just spying on an exception that was already forwarded to the parent
o

Oliver.O

10/09/2023, 6:18 PM
Yes, one could certainly look at it that way. A bit strange though, as we do not have an "exception spying" concept anywhere else. Also, there are no `CancellationException`s in between (these would have been reported by
reportCatching
, but, obviously, the exception handling mechanism cannot deal with two active exceptions in parallel).
So, yes, maybe it is still the right thing to do, but still seems like an exceptional way of dealing with exceptions.
l

louiscad

10/11/2023, 12:50 PM
You should catch either within the async block, or outside of the local
coroutineScope { … }
. That is structured concurrency.
o

Oliver.O

10/11/2023, 1:03 PM
The Deferred docs state
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.
A
coroutineScope
could contain multiple
await
calls, and I might want to find out which one produced an exception.
l

louiscad

10/11/2023, 1:03 PM
Just catch/wrap exceptions, and unwrap them after calling await, then.
Anything that isn't a CancellationException that you're not catching iniside a coroutine builder like launch or async will crash and cancel the parent scope.
o

Oliver.O

10/11/2023, 1:39 PM
I'd agree if "crashing" means propagating the exception, cancelling jobs on its journey upwards. I'm still not convinced that we have a completely accurate picture here. The docs on Exception propagation state:
Coroutine 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.
l

louiscad

10/11/2023, 1:41 PM
The doc has room for improvement probably, I think the part you're quoting is misleading, actually. It's open source, you can submit PRs. I told you the behavior, which is intended. If you don't want said behavior, you can use
supervisorScope { }
o

Oliver.O

10/11/2023, 1:43 PM
What's wrong with this example in the docs?
Copy code
try {
        deferred.await()
        println("Unreached")
    } catch (e: ArithmeticException) {
        println("Caught ArithmeticException")
    }
l

louiscad

10/11/2023, 1:44 PM
It's not catching the exception at the right place.
It should be inside the async block or outside the parent scope.
cc @Vsevolod Tolstopyatov [JB] on misleading / outdated docs
o

Oliver.O

10/11/2023, 1:48 PM
👍 I was about to ping Dmitry, but let's not over-allocate resources. 😉
j

Jacob

10/11/2023, 1:49 PM
Yeah a lot of docs predate structured concurrency. They’re still valid depending on the configuration of the outer scope. Ie for a global scope or supervisor scope
o

Oliver.O

10/11/2023, 8:54 PM
Having re-checked relevant places, I could not find any evidence that backs up the "outdated documentation" claim: • Structured concurrency was released with version 0.26.0 on Sep 12, 2018. • The comment "[await] method, which throws exception if the deferred had failed." was added on Sep 23, 2018 by Roman. So to me, it seems intentional that exceptions thrown by an
await
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.
also see https://github.com/Kotlin/kotlinx.coroutines/pull/1886 which tried to update the docs, partially in response to https://github.com/Kotlin/kotlinx.coroutines/issues/871 but IMO they still aren’t great.
The key part of the fix to the docs is that it specified that it only applies to "root" coroutines. As the concept of root coroutines isn't quite introduced properly, I don't think it's an adequate fix.
o

Oliver.O

10/12/2023, 11:33 AM
Now we're talking! Thanks for pointing to these references. The docs on
Deferred
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 than
CancellationException
, it cancels its parent with that exception. This behaviour cannot be overridden
I 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
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!
j

Jacob

10/12/2023, 2:30 PM
Having 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 a
launch
or
async
call doesn’t make much sense,
Sure it does. ie if using nulls to represent failures:
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-8062f589d07
o

Oliver.O

10/12/2023, 2:40 PM
You're catching inside an
async
call. I was referring to
try { async { ... } } catch (...) { ... }
, which doesn't make much sense. Or did I miss something?
j

Jacob

10/12/2023, 2:41 PM
ahh I misunderstood you. I agree that that would not make much sense
👍 1
5 Views