DEV Community

Cover image for Mastering runCatching in Kotlin: How to Avoid Coroutine Cancellation Issues
Valerii Popov
Valerii Popov

Posted on

Mastering runCatching in Kotlin: How to Avoid Coroutine Cancellation Issues

The runCatching function in Kotlin is a powerful tool that lets you handle exceptions within a block of code while preserving the result. However, when working with coroutines, runCatching can introduce unexpected issues. Because it catches all exceptions, including CancellationException, it can interfere with proper coroutine cancellation. In this article, we’ll explore how runCatching works, its potential pitfalls in coroutines, and how to build custom Result extensions to handle exceptions safely without impacting cancellation.

What is runCatching?

runCatching is a utility function in Kotlin that executes a block of code and wraps the result in a Result object. If the block completes successfully, it returns a Result with the value. If an exception occurs, it wraps the exception instead, so you can handle errors more concisely without try-catch blocks.

Here’s an example of how runCatching is typically used:

val result = runCatching {
    // Some operation that might throw an exception
    performNetworkRequest()
}.onSuccess {
    println("Success: $it")
}.onFailure {
    println("Error: ${it.message}")
}
Enter fullscreen mode Exit fullscreen mode

Pitfall: Catching CancellationException

In Kotlin, coroutine cancellations are controlled by throwing CancellationException. When a coroutine is cancelled, it throws this exception up the call stack, which propagates the cancellation signal. However, because runCatching catches all exceptions, including CancellationException, it can intercept and handle cancellation attempts unintentionally, preventing the coroutine from being properly cancelled.

Example of runCatching Interfering with Cancellation:

// Hypothetical Retrofit service with a function that returns a Response<String>
interface ApiService {
    suspend fun fetchDataFromServer(): Response<String>
}

// Function that performs a network call and handles errors using runCatching
suspend fun fetchData(apiService: ApiService): Result<String> {
    return runCatching {
        val response = apiService.fetchDataFromServer()

        // Check if the response is successful
        if (response.isSuccessful) {
            response.body() ?: throw Exception("Empty response body")
        } else {
            throw HttpException(response)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In this code, runCatching is used to wrap the Retrofit network call. This might look like a concise way to handle errors, but it has an important flaw when dealing with coroutines: runCatching catches all exceptions, including CancellationException.
When fetchDataFromServer() is called in a coroutine and that coroutine is cancelled, CancellationException is thrown to signal that the coroutine should stop running. However, because runCatching catches all exceptions indiscriminately, it will catch the CancellationException along with any other exceptions. This can prevent the coroutine from cancelling correctly, which may lead to unexpected issues such as unresponsive UI or memory leaks.

Solution: Custom Extension Functions for Result

To safely handle exceptions without interfering with coroutine cancellation, we can create custom extensions on Result:

  • onFailureOrRethrow: An extension function that lets you specify an exception type to rethrow, allowing other exceptions to be handled normally.

  • onFailureIgnoreCancellation: An extension that specifically ignores CancellationException, allowing you to handle other exceptions without blocking coroutine cancellation.

Here’s how to implement these extensions:

inline fun <reified E : Throwable, T> Result<T>.onFailureOrRethrow(action: (Throwable) -> Unit): Result<T> {
    return onFailure { if (it is E) throw it else action(it) }
}

inline fun <T> Result<T>.onFailureIgnoreCancellation(action: (Throwable) -> Unit): Result<T> {
    return onFailureOrRethrow<CancellationException, T>(action)
}
Enter fullscreen mode Exit fullscreen mode

With these extensions, you can handle failures more selectively:

val result = runCatching {
    val response = apiService.fetchDataFromServer()
    if (response.isSuccessful) {
        response.body() ?: throw Exception("Empty response body")
    } else {
        throw HttpException(response)
    }
}.onFailureIgnoreCancellation {
    // Handle errors other than CancellationException
    println("Handled non-cancellation error: ${it.message}")
}
Enter fullscreen mode Exit fullscreen mode

Here, if a CancellationException is thrown, it bypasses the failure handler, allowing the coroutine to cancel as intended.

Conclusion

Using runCatching in Kotlin simplifies exception handling but requires extra caution in coroutines due to its tendency to catch all exceptions, including CancellationException. By creating custom extensions like onFailureOrRethrow and onFailureIgnoreCancellation, you can ensure that your code handles errors flexibly while respecting coroutine cancellation.

These extensions provide a safer, more reliable approach to managing errors in coroutines and can be incorporated into your codebase to streamline exception handling in a variety of coroutine contexts.

Top comments (0)