Skip to content

CancellationException inheriting from IllegalStateException can be error prone #4073

Open
@kevincianfarini

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.

  1. It helps mitigate inexperienced engineers from accidentally catching CancellationException when they broadly catch Exception or RuntimeException. (This is not foolproof if they also catch Throwable).
  2. It helps differentiate between catching an IllegalStateException versus CancellationException. Throwing generic instances of ISE was made popular by Kotlin functions like check and checkNotNull.
  3. It decouples this library from Java's CancellationException which also suffers from this issue.

The downsides of your proposal that you already see.

  1. This would be a breaking change.
  2. 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.

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions