Skip to main content

Kotlin Coroutine Exceptions

Error handling is a critical aspect of any application, but it becomes even more important when dealing with asynchronous code. Kotlin Coroutines provide several mechanisms for handling exceptions that occur during asynchronous operations. In this article, we'll explore how exceptions propagate in coroutines and how to handle them effectively.

Introduction to Exception Handling in Coroutinesā€‹

When working with Kotlin Coroutines, exception handling follows different rules than synchronous code. Since coroutines can be executed concurrently and in different contexts, the standard try-catch mechanism might not always work as expected.

Coroutines build upon Kotlin's built-in exception handling mechanisms but introduce specific behaviors and tools for managing errors in asynchronous code.

Exception Propagation in Coroutinesā€‹

Let's start by understanding how exceptions propagate in coroutines:

Automatic Propagationā€‹

By default, exceptions in coroutines automatically propagate to their parent coroutine:

kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
try {
launch {
println("Throwing an exception from launch")
throw RuntimeException("šŸ”„ Something went wrong")
}
} catch (e: Exception) {
println("Caught: ${e.message}") // This won't catch the exception!
}

delay(100) // Wait for the launched coroutine to complete and throw exception
println("This line will not be reached")
}

// Output:
// Throwing an exception from launch
// Exception in thread "main" java.lang.RuntimeException: šŸ”„ Something went wrong
// at MainKt$main$1$1.invokeSuspend(Main.kt:8)
// ...

In the example above, the exception isn't caught by the try-catch block. This is because the exception is thrown in a child coroutine and propagates to the parent coroutine (runBlocking), which causes the application to crash.

CoroutineExceptionHandlerā€‹

To handle exceptions in coroutines, you can use a CoroutineExceptionHandler:

kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
val exceptionHandler = CoroutineExceptionHandler { _, exception ->
println("Caught exception: ${exception.message}")
}

val job = GlobalScope.launch(exceptionHandler) {
println("Throwing an exception from launch")
throw RuntimeException("šŸ”„ Something went wrong")
}

job.join() // Wait for the coroutine to complete

println("Continues after exception...")
}

// Output:
// Throwing an exception from launch
// Caught exception: šŸ”„ Something went wrong
// Continues after exception...

The CoroutineExceptionHandler is a coroutine context element that handles uncaught exceptions. It's useful for "top-level" coroutines that don't have a parent that can handle their exceptions.

Structured Concurrency and Exception Handlingā€‹

Structured concurrency in Kotlin Coroutines impacts how exceptions are handled. When a coroutine is launched within a coroutine scope, any uncaught exception in the child coroutine will cancel the parent scope and all its children:

kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
val scope = CoroutineScope(Job())

scope.launch {
launch {
delay(100)
println("Child 1 is working...")
delay(1000)
println("Child 1 completes") // This won't be printed
}

launch {
delay(200)
println("Child 2 is working...")
throw RuntimeException("Child 2 failed")
}
}

delay(300) // Give some time for coroutines to execute
scope.coroutineContext.job.children.forEach { it.join() }

println("Done")
}

// Output:
// Child 1 is working...
// Child 2 is working...
// Exception in thread "DefaultDispatcher-worker-1" java.lang.RuntimeException: Child 2 failed
// ...
// Done

Notice that "Child 1 completes" is never printed because the exception in Child 2 cancels the entire scope, including Child 1.

SupervisorJob for Independent Childrenā€‹

If you want child coroutines to fail independently without affecting siblings, use SupervisorJob:

kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
val exceptionHandler = CoroutineExceptionHandler { _, exception ->
println("Caught: ${exception.message}")
}

val supervisor = SupervisorJob()
val scope = CoroutineScope(supervisor + exceptionHandler)

val child1 = scope.launch {
delay(100)
println("Child 1 is working...")
delay(1000)
println("Child 1 completes")
}

val child2 = scope.launch {
delay(200)
println("Child 2 is working...")
throw RuntimeException("Child 2 failed")
}

// Wait for both children
joinAll(child1, child2)

println("Done")
}

// Output:
// Child 1 is working...
// Child 2 is working...
// Caught: Child 2 failed
// Child 1 completes
// Done

With SupervisorJob, Child 1 continues to run even though Child 2 fails.

supervisorScope for Limited Supervisionā€‹

If you need supervision for just a specific part of your code, use supervisorScope:

kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
try {
supervisorScope {
val child1 = launch {
try {
println("Child 1 is working")
delay(1000)
println("Child 1 completes")
} catch (e: CancellationException) {
println("Child 1 was cancelled")
}
}

val child2 = launch {
try {
println("Child 2 is working")
delay(500)
throw RuntimeException("Child 2 fails")
} catch (e: CancellationException) {
println("Child 2 was cancelled")
}
}
}
} catch (e: Exception) {
println("Caught: ${e.message}")
}

println("Done")
}

// Output:
// Child 1 is working
// Child 2 is working
// Caught: Child 2 fails
// Done

Here, the exception from Child 2 doesn't affect Child 1, but it still propagates up to the caller of supervisorScope.

Try-Catch with suspend Functionsā€‹

For suspending functions, you can use traditional try-catch blocks:

kotlin
import kotlinx.coroutines.*

suspend fun riskyOperation(): String {
delay(500)
if (Math.random() > 0.5) {
throw RuntimeException("Operation failed")
}
return "Operation succeeded"
}

fun main() = runBlocking {
try {
val result = riskyOperation()
println("Result: $result")
} catch (e: Exception) {
println("Caught exception: ${e.message}")
}

println("Program continues...")
}

// Possible output 1:
// Result: Operation succeeded
// Program continues...

// Possible output 2:
// Caught exception: Operation failed
// Program continues...

This works because the exception is thrown directly from the suspend function, not from a child coroutine.

Exception Handling with asyncā€‹

The async builder works differently from launch when it comes to exceptions:

kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
val deferred = async {
println("Throwing exception in async")
throw RuntimeException("Error in async")
"This is never returned"
}

try {
// The exception is thrown only when we call await()
val result = deferred.await()
println("Result: $result")
} catch (e: Exception) {
println("Caught exception: ${e.message}")
}
}

// Output:
// Throwing exception in async
// Caught exception: Error in async

With async, exceptions are not thrown immediately but are stored and rethrown when you call await(). This allows you to handle exceptions at the point where you need the result.

Cancellation and Exceptionsā€‹

When a coroutine is cancelled, a special exception called CancellationException is thrown:

kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
val job = launch {
try {
repeat(1000) { i ->
println("Job: I'm working $i ...")
delay(500L)
}
} catch (e: CancellationException) {
println("Job: I was cancelled")
throw e // Re-throwing is important for cancellation to propagate
} finally {
println("Job: I'm running cleanup code")
}
}

delay(1300L) // Let it work for a while
println("Main: I'm tired of waiting!")
job.cancelAndJoin() // Cancels the job and waits for its completion
println("Main: Now I can continue")
}

// Output:
// Job: I'm working 0 ...
// Job: I'm working 1 ...
// Job: I'm working 2 ...
// Main: I'm tired of waiting!
// Job: I was cancelled
// Job: I'm running cleanup code
// Main: Now I can continue

It's important to note that you should re-throw CancellationException for proper cancellation propagation. The finally block is a good place for cleanup operations.

Real-World Example: Handling Network Requestsā€‹

Let's look at a more practical example of exception handling in a network request scenario:

kotlin
import kotlinx.coroutines.*
import java.io.IOException
import kotlin.random.Random

// Simulated network service
class UserService {
suspend fun fetchUser(userId: Int): User {
delay(1000) // Simulate network delay
if (Random.nextInt(0, 10) > 7) { // 30% chance of failure
throw IOException("Network error")
}
if (userId <= 0) {
throw IllegalArgumentException("Invalid user ID")
}
return User(userId, "User $userId")
}
}

data class User(val id: Int, val name: String)

suspend fun getUserSafely(userId: Int): Result<User> {
return try {
val service = UserService()
Result.success(service.fetchUser(userId))
} catch (e: IOException) {
println("Network error: ${e.message}")
Result.failure(e)
} catch (e: IllegalArgumentException) {
println("Bad request: ${e.message}")
Result.failure(e)
} catch (e: Exception) {
println("Unknown error: ${e.message}")
Result.failure(e)
}
}

fun main() = runBlocking {
// Retry mechanism
val user = retry(times = 3) {
val result = getUserSafely(5)
if (result.isFailure && result.exceptionOrNull() is IOException) {
throw result.exceptionOrNull()!! // Retry only for network errors
}
result.getOrThrow()
}

println("Successfully retrieved user: $user")
}

suspend fun <T> retry(
times: Int,
initialDelayMs: Long = 100,
maxDelayMs: Long = 1000,
factor: Double = 2.0,
block: suspend () -> T
): T {
var currentDelay = initialDelayMs
repeat(times - 1) { attempt ->
try {
return block()
} catch (e: Exception) {
println("Attempt ${attempt + 1}/$times failed with: ${e.message}")
}
delay(currentDelay)
currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelayMs)
}
// Last attempt
return block()
}

This example demonstrates several real-world patterns:

  1. Using Result to handle exceptions in a more functional way
  2. Implementing a retry mechanism for transient failures
  3. Differentiating between different types of exceptions
  4. Implementing exponential backoff for retries

Best Practices for Exception Handlingā€‹

  1. Use CoroutineExceptionHandler for root coroutines: Always provide an exception handler for top-level coroutines.

  2. Choose the right scope: Use supervisorScope when you want child coroutines to fail independently.

  3. Don't swallow CancellationException: Always re-throw CancellationException to ensure proper cancellation propagation.

  4. Clean up resources in finally blocks: The finally block is guaranteed to execute, making it perfect for cleanup.

  5. Handle exceptions at the appropriate level: Handle exceptions where you have enough context to take appropriate action.

  6. Use Result for functional error handling: The Result class provides a more functional approach to error handling.

Summaryā€‹

Exception handling in Kotlin Coroutines requires understanding how exceptions propagate through the coroutine hierarchy. Key concepts to remember:

  • Exceptions in coroutines propagate up the coroutine hierarchy
  • CoroutineExceptionHandler catches exceptions from root coroutines
  • SupervisorJob and supervisorScope allow children to fail independently
  • async defers exceptions until await() is called
  • Always handle CancellationException properly

By mastering these concepts, you'll build more resilient asynchronous applications that can gracefully handle errors and avoid app crashes.

Additional Resourcesā€‹

Exercisesā€‹

  1. Create a function that makes three network calls in parallel using async and handles errors for each call independently.

  2. Implement a timeout mechanism that cancels a long-running operation and handles the cancellation gracefully.

  3. Build a retry mechanism with exponential backoff for a flaky API call.

  4. Create a supervisorScope that manages multiple background tasks and collects all errors that occurred during execution.

  5. Implement a resource pool that properly releases resources in case of exceptions or cancellations.



If you spot any mistakes on this website, please let me know at [email protected]. Iā€™d greatly appreciate your feedback! :)