CancellationException inheriting from IllegalStateException can be error prone #4073
Description
What do we have now?
Currently, CancellationException
inherits from IllegalStateException
. My understanding is this was done to maintain compatibility with Java's CancellationException
.
What should be done instead?
Coroutines < 2.0.0
@Deprecated("Use CancellationSignal")
open class CancellationException : IllegalStateException
class CancellationSignal : CancellationException()
Coroutines >= 2.0.0
class CancellationSignal : Throwable()
Why?
Over my several years using kotlinx-coroutines, I've noticed that this is something that regularly tricks people and unintentionally breaks coroutine cancellation. Most recently I've run into this while reviewing code that handles our authentication path. During authentication, if the server returns a token that is not valid for whatever reason (in this case the schema value is nullable, but this value should never actually be null) we throw an ISE.
suspend fun authenticate(): Token {
val response: AuthenticationResponse = TODO("Do the server authentication")
return Token(
jwt = checkNotNull(response.jwt) { "Response JWT was null!" }
)
}
There's very few instances where you'd actually want to catch IllegalStateException
, but the one we found ourselves in was ensuring that a user was not in a partially logged in state if acquiring their token failed. In a sense, authentication had to be transactional. We have other services we "authenticate" with aside from acquiring a JWT. Below is roughly analogous to the code I reviewed.
suspend fun login(email: String, password: String) {
try {
service.authenticate(...)
} catch (e: CustomNetworkError) {
rollbackLoginTransaction()
showErrorMessage()
} catch (e: IllegalStateException) {
rollbackLoginTransaction()
showErrorMessage()
}
}
There's a subtle bug here. My other coworker, who is a senior engineer, did not realize that CancellationException
inherited from ISE. After pointing this out, he added the following line:
suspend fun login(email: String, password: String) {
try {
service.authenticate(...)
} catch (e: CustomNetworkError) {
rollbackLoginTransaction()
showErrorMessage()
} catch (e: IllegalStateException) {
rollbackLoginTransaction()
+ if (e is CancellationException) throw e
showErrorMessage()
}
}
I've run into issues like this one fairly frequently in code review. This issue is particularly prevalent with more junior engineers who catch broad Exception
types; that issue is more easily recognizable and mitigated though. Catching ISE is a more subtle failure that many senior engineers don't even realize is an issue on our team. This is further complicated by Kotlin's offering of utilities like error
, check
and checkNotNull
. Encountering generic instances of ISE in Kotlin is fairly common, and most instances where you might need to catch ISE can be error prone because of CancellationException
.
My intention is to raise this issue as a consideration for the next major release of kotlinx-coroutines. Lots of people have raised error prone code that accidentally catches CancellationException
(see: #1814). While we can't solve every single error scenario here while also using exceptions to propagate cancellation, I believe that we should consider reducing the likelihood that someone accidentally catches CancellationException
.
The upsides of your proposal.
- It helps mitigate inexperienced engineers from accidentally catching
CancellationException
when they broadly catchException
orRuntimeException
. (This is not foolproof if they also catchThrowable
). - It helps differentiate between catching an
IllegalStateException
versusCancellationException
. Throwing generic instances of ISE was made popular by Kotlin functions likecheck
andcheckNotNull
. - It decouples this library from Java's
CancellationException
which also suffers from this issue.
The downsides of your proposal that you already see.
- This would be a breaking change.
- We would decouple ourselves from Java's
CancellationException
. This decision was originally made with intention, but I am not sure what practical benefits it provides us.