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}")
}
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)
}
}
}
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 ignoresCancellationException
, 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)
}
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}")
}
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.