-
Notifications
You must be signed in to change notification settings - Fork 1.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Reconsider and/or better document handling of exceptions thrown by await() #3937
Comments
I don't think I understand the problem or the solutions. Let's first make sure we're on the same page. https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/async.html states:
and
On
This behaviour can also be easily observed like this: fun main() = runTest {
async { throw TestException() }
try {
delay(10.milliseconds) // will throw an exception, because the parent coroutine got cancelled in the meantime
} catch (e: Exception) {
println("delay threw")
}
} Semantically, you can think of Occasionally, the class ApiAccessor(scope: CoroutineScope) {
fun getUser(id: Int): Deferred<User> = scope.async {
if (!checkId(id)) throw IllegalStateException("wrong id")
downloadUserData(id)
}
}
fun main() {
val apiScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val api = ApiAccessor(apiScope)
runBlocking {
launch {
delay(1.seconds)
api.cancel() // disable the API access after one second of work
}
repeat(1000) {
val userDeferred = api.getUser(1000)
val user = runCatching { userDeferred.await() } // waits for the user, failure does not cancel the scope
}
}
} The link to the discussion doesn't open for me for some reason, unfortunately. Does this answer your concerns? |
Here's a link to the Slack discussion on Linen: https://slack-chats.kotlinlang.org/t/15971244/after-catching-an-async-call-s-exception-why-does-it-re-thro First, I agree with everything you wrote. The differences are in the details which are implied in the docs rather than explicitly stated. For example, does "cancel" refer to a cancellation via
Yes, but how is the parent job cancelled? Via a But there is https://kotlinlang.org/docs/exception-handling.html#cancellation-and-exceptions, which states:
So we can assume in this case that the parent job is cancelled with a Continuing to quote from the previous post:
Now, in "inner, bare", https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/await.html says:
So we might think that Now we have two active
Yes, the difference being that
Afterward, the parent's cancellation with the original exception would appear:
Here we have
Yes, I have always looked at it that way.
If I'm not overlooking something, that scenario would also be fine as there would not be multiple active exceptions in the same scope. The original problem just comes up with Does that better explain the problem? |
I don't think so, sorry. I see a couple of suspicious turns of phrase on your side, though. Let's clarify them to make sure there is no misunderstanding. From the discussion you linked:
From the issue text:
and
I simply don't understand what an "active exception" or a "double-throw" is. There's no such thing as several exceptions in flight simultaneously, the language doesn't permit that. Let's manually recreate what happens, without using fun main1() = runTest {
val deferred: Deferred<Int> = async {
throw TestException()
}
testScheduler.advanceUntilIdle() // make sure the work does finish and the parent gets cancelled
println("The exception from the spawned-off coroutine: ${deferred.getCompletionExceptionOrNull()}")
try {
delay(1000)
} catch (e: Exception) {
println("Structured concurrency returned an error: $e")
}
}
fun main2() = runTest {
var result: Result<Int>? = null
launch {
val exception = TestException()
result = Result.failure(exception)
throw exception
}
testScheduler.advanceUntilIdle() // make sure the work does finish and the parent gets cancelled
println("The exception from the spawned-off coroutine: ${result!!.exceptionOrNull()}")
try {
delay(1000)
} catch (e: Exception) {
println("Structured concurrency returned an error: $e")
}
} Here, the combination of In both cases, we observe three exceptions:
Do you think the behavior of |
Agreed. And you are right that "in-flight simultaneously" or (as I phrased it) "in parallel" does not accurate describe it. What happens in my example is that the same exception (by identity), although caught and not explicitly rethrown, is recycled and thrown a second time. Let's go through your examples first. Running
We're seeing
Two exceptions, each thrown once and caught once: Exactly the same occurs in
No. Both cases look perfectly fine.
The difference being "handled". Which in this case is not catching, but storing the exception.
It does not get rethrown on
Semantically, it does not (explicitly) get "rethrown" but just continues to bubble up. (All code from
Yes, and the term "almost" is what breaks the expectations learned from non-concurrent exception handling: Once an exception is caught and not explicitly rethrown, it is "dead". No implicit mechanism will interfere and try to revive (throw once more) that "dead" exception. (Throwing a different exception for other reasons would be another thing.) Let's illustrate it by this example, which catches from fun main() = runTest {
var awaitException: Throwable? = null
try {
coroutineScope {
val deferred = async {
throw TestException("boom")
}
testScheduler.advanceUntilIdle() // make sure the work does finish and the parent gets cancelled
try {
deferred.await()
} catch (e: Throwable) {
println("caught from await: $e")
awaitException = e
}
}
} catch (e: Throwable) {
println("caught from outer scope: $e.")
println("Are the exceptions caught from 'outer scope' and 'await' identical? ${e === awaitException}")
}
} Running it:
So that just seems strange and that special case would – in my opinion – either better be explicitly illustrated in the docs or be made explicit by a different kind of (wrapper) exception. Why do I think this needs clarification? I would have probably never come across that case if it were just me. Mainly, because I'm doing event-based stuff and almost don't use |
Ok, thank you, I think I'm starting to get it. Just to make sure: is this also strange? fun main() = runTest {
val deferred = CompletableDeferred<Int>()
deferred.completeExceptionally(TestException())
val exception1: Exception
try {
deferred.await()
return@runTest
} catch (e: Exception) {
exception1 = e
}
val exception2: Exception
try {
deferred.await()
return@runTest
} catch (e: Exception) {
exception2 = e
}
if (exception1 === exception2) {
println("got the same exception $exception1 twice")
}
} Here, we process an exception once, but then we can obtain exactly the same exception a second time, even though it should be "dead." |
That's an interesting example. I'm taking it that This is also somewhat strange. The user might ask: Who throws initially? If it is the Actually, it is
|
Fixes two issues: * It is surprising for some users that the same exception can be thrown several times. Clarified this point explicitly. * Due to #3658, `await` can throw `CancellationException` in several cases: when the `await` call is cancelled, or when the `Deferred` is cancelled. This is clarified with an example of how to handle this. Fixes #3937
Fixes two issues: * It is surprising for some users that the same exception can be thrown several times. Clarified this point explicitly. * Due to #3658, `await` can throw `CancellationException` in several cases: when the `await` call is cancelled, or when the `Deferred` is cancelled. This is clarified with an example of how to handle this. Fixes #3937
An exception thrown by an
await()
call can be caught, but will then be re-thrown again in the enclosing coroutine scope, apparently from out of nowhere. Re-throwing an exception which has just been caught breaks normal exception behavior and contradicts user expectations.Example:
This would print:
While this issue has been raised before (in #2147), I don't think it has been properly addressed.
Currently, correct usage would rely on users being aware of the implications regarding structured concurrency, the
Job
hierarchy and the interwoven timing between launching and completion of jobs. In particular, the following snippet from the section on Cancellation and exceptions could be interpreted as implying the above behavior (emphasis added):However, the
async/await
situation is not mentioned and there is no explanation of the underlying mechanism and its effect onawait()
. While with normal coroutine operations, exception handling is consistent with sequential operations, in case ofawait()
the situation causes misinterpretations and errors.Discussion: https://kotlinlang.slack.com/archives/C1CFAFJSK/p1696873014170159
Two solutions seem possible:
await()
call throw a special exception likeDeferredResultUnavailable
, wrapping the original one.await()
call is discouraged.In any case, the docs should explicitly mention this case.
The text was updated successfully, but these errors were encountered: