Idiomatic way to cancel coroutine from within a suspend function


#1

I have written a little API that is capable of handling exceptions that occur within a coroutine. The basic implementation of this API goes as follows:

interface ErrorHandler {
    suspend fun handleException(error: Exception): ErrorHandling
}

enum class ErrorHandling { RETRY, RETHROW, CANCEL }

suspend fun <T> withErrorHandler(errorHandler: ErrorHandler, c: suspend () -> T): T {
    loop@while (true) {
        try {
            return c();
        } catch (e: Exception) {
            when (errorHandler.handleException(e)) {
                RETRY -> continue@loop
                RETHROW -> throw e
                CANCEL -> TODO("Find a way to cancel the coroutine (and its parents?)")
            }
        }
    }
}

Examples of implementations of the ErrorHandler interface are:

ExponentialBackoffErrorHandler
For some error types, this error handler will return RETRY after some exponentially growing delay. It will return RETHROW when max retries are reached.

DialogErrorHandler
Shows an error dialog to the user. For some error types, the dialog can include buttons for RETRY’ing or CANCEL’ing the execution.

Example of how the API can be used:

launch (UI) {
    val foo = withErrorHandler(dialogErrorHandler) {
        serviceApi.getFoo(fooId).await()
    }

    val bar = withErrorHandler(dialogErrorHandler) {
        serviceApi.getBar(foo.barId).await()
    }

    updateUI(bar)
}

My question is how to cancel the coroutine in the most idiomatic way, when the user chooses CANCEL from within the error dialog (see TODO in the above code). Simply returning will not work, since then the coroutine would continue and expect some result, which will not be available.


#2

From inside the coroutines you can do coroutineContext.cancel(). The only missing part, for now, is that coroutineContext is not yet available inside regular suspending functions (only inside builders like launch and async). It will be soon, though. See this ticket. It also has a work-around: https://youtrack.jetbrains.com/issue/KT-17609


#3

That’s great, thank you.

I would like to express my devotion to you and your colleagues at Jetbrains. You are Rockstars. The James Goslings and Neal Gafters of this decade.


#4

I tried implementing the suggestion. The code now looks as follows:

suspend fun <T> withErrorHandler(errorHandler: ErrorHandler, block: suspend () -> T): T {
    loop@while (true) {
        try {
            return block();
        } catch (e: Exception) {
            when (errorHandler.handleException(e)) {
                RETRY -> continue@loop
                RETHROW -> throw e

                // Cancel the coroutine as suggested:
                CANCEL -> coroutineContext().cancel() 
            }
        }
    }
}

Invoking coroutineContext().cancel() indeed cancels the coroutine. But of course it does not abort the execution of my while loop. The while loop will try to execute the block one more time, which will yield a JobCancellationException: Job was cancelled normally. This in turn will be handled again by my dialogErrorHandler, which will show another dialog for the cancellation exception

I am not sure whether I fully understand what is happening here. I understand why the JobCancellationException is thrown. But I do not understand, why am I still allowed to invoke the suspending handleException function? Shouldn’t this yield another cancellation exception?

Nevertheless, what is the best way to fix this?

Possible solutions:

  • replace the while(true) with while(isActive). Problem: this way the compiler will complain that I need to return a result after the while loop (and I do not have a result).

  • Check whether the Exception is a JobCancellationException (or its parent CancellationException), and re-throw it instead of invoking the handleException function. Problem: seems to be less ideal, as it will execute the while loop one last time, knowing that it will just yield the exception.

  • After canceling the coroutine, throw an exception myself. Can I throw a CancellationException myself for this purpose? Do I even need to cancel the coroutine before throwing this exception, or can I simply just throw the CancellationException without invoking coroutineContext().cancel() before?


#5

You should indeed loop while (isActive) and you should throw exception when it is cancelled. You can either rely on some other cancellable suspending function to throw it for you (delay and yield both throw it when the job is cancelled) or throw it yourself using throw job.getCancellationException().


#6

Since the withErrorHandler function needs to return a result, it seems like I need to throw the exception myself. I did this now with

throw coroutineContext()[Job]?.getCancellationException() ?: CancellationException()

I am not sure about whether my handling of the nullable coroutineContext()[Job] makes any sense. Maybe there is another way to retrieve the current coroutine’s non-null Job. Anyway, this seems to work now in my tests so far.


#7

You can do coroutineContext()[Job]!!.getCancellationException(). As long as you always using coroutine builders from kotlinx.coroutines library (like launch and async) there is always a Job in the context. Alternatively, you can just use yield() and you will not have to think about it at all.


#8

I cannot use yield() as it will not solve the problem that I need to return a value of type T in my withErrorHandler function. In the case of CANCEL, I don’t have a value to return, so I can only throw an Exception. Otherwise the compiler will complain that I need to return a T.


#9

You can use this pattern:

while (true) {
    // try return result here

   yield() // before looping, will check for cancel, too
}

#10

Yes it will work with while(true), but it will not work with while(isActive). The compiler will complain that I need to return a T after the while loop.