Baeldung Pro – Kotlin – NPI EA (cat = Baeldung on Kotlin)
announcement - icon

Learn through the super-clean Baeldung Pro experience:

>> Membership and Baeldung Pro.

No ads, dark-mode and 6 months free of IntelliJ Idea Ultimate to start with.

1. Introduction

Handling exceptions is critical to building resilient applications, and Kotlin Flow simplifies this with the catch() operator. This allows us to handle exceptions gracefully during a Flow‘s emission of values. Whether we want to log errors, emit fallback values, or implement retry logic, catch() helps maintain a flow’s lifecycle without abrupt termination. 

This tutorial explores various ways to use the catch() operator effectively, illustrated with clear examples and unit tests.

2. Basic Usage of the catch() Operator

The catch() operator lets us capture and handle exceptions upstream in a Flow. Instead of terminating the Flow abruptly on error, we can intercept it and decide how to proceed:

@Test
fun `catch operator logs exception`() = runTest {
    val emittedValues = mutableListOf<Int>()
    val flow = flow {
        emit(1)
        emit(2)
        throw IllegalStateException("Test exception")
    }

    flow.catch { e ->
        assertEquals("Test exception", e.message)
    }.collect {
        emittedValues.add(it)
    }

    assertEquals(listOf(1, 2), emittedValues)
}

In this test, the Flow emits two integers before throwing an exception. The catch() block captures the exception and verifies its message. Then we collect the emitted values to a List to confirm that the Flow still completes successfully. This ensures that the Flow handles errors gracefully without crashing.

3. Emitting a Fallback Value in catch()

Sometimes we might want to emit() a fallback value instead of terminating the Flow when an error occurs:

@Test
fun `catch operator emits fallback value`() = runTest {
    val emittedValues = mutableListOf<Int>()
    val flow = flow {
        emit(1)
        throw IllegalArgumentException("Simulated error")
    }

    flow.catch {
        emit(-1) 
    }.collect {
        emittedValues.add(it)
    }

    assertEquals(listOf(1, -1), emittedValues)
}

The Flow emits a single value before throwing an exception. The catch() block also emits a value. Then we ensure that the collector still receives both emissions. This approach is ideal for providing default data when the flow fails.

4. Combining catch() With Retry Logic

Transient errors such as network issues can sometimes be resolved with retries. Kotlin Flow allows us to combine the catch() operator with retry mechanisms to handle these situations elegantly:

@Test
fun `catch operator works with retries`() = runTest {
    var attempt = 0
    val emittedValues = mutableListOf<Int>()
    val flow = flow {
        if (attempt++ < 3){
            throw RuntimeException("Network error")
        }
        emit(1)
    }

    flow.retry(3) { it is RuntimeException }
        .catch { 
            emit(-1)
        }
        .collect {
            emittedValues.add(it)
        }

    assertEquals(listOf(1), emittedValues)
}

This test simulates a Flow that fails three times before successfully emitting a value. The retry() operator ensures that the Flow is re-executed up to three times whenever a RuntimeException occurs.

If the error persists beyond three attempts, it propagates to the catch() block, which emits a negative one. However, because our retry succeeds, the exception never reaches catch(), as the error is effectively “swallowed” during the retry process. This means that catch() will only execute if all retry attempts fail, ensuring that the Flow either recovers from transient failures or gracefully handles an unrecoverable error.

5. Handling Specific Exceptions With Conditional Logic

Sometimes, we may want to handle specific types of exceptions differently. We can also inspect the exception in the catch() and handle errors differently:

@Test
fun `catch operator handles specific exceptions`() = runTest {
    val emittedValues = mutableListOf<Int>()
    val flow = flow {
        emit(1)
        throw IllegalStateException("Recoverable error")
    }

    flow.catch { e ->
        if (e is IllegalArgumentException) {
            emit(-1)
        } else {
            throw e
        }
    }.collect {
        emittedValues.add(it)
    }

    assertEquals(listOf(1, -1), emittedValues)
}

The Flow emits a value before throwing an exception. Then this test demonstrates how the catch() operator can selectively handle specific exceptions, such as an IllegalArgumentException.

The IllegalArgumentException is treated as recoverable and we emit a value when handling it. All other exceptions are rethrown to avoid suppressing critical issues. This ensures a balance between error recovery and transparency, allowing the collector to continue when encountering recoverable errors.

6. Benefits of Using the catch() Operator

The catch() operator enhances error handling in Kotlin Flow by providing a structured and readable approach to managing exceptions:

  • Smooth Error Management: It prevents crashes by handling exceptions gracefully, ensuring data flows continue without disruption.
  • Targeted Exception Handlingcatch() only intercepts upstream errors, allowing downstream exceptions to propagate naturally, maintaining precise control over error handling.
  • Flexible Responses: Developers can log errors, provide alternative values, or rethrow exceptions, adapting to various application needs.
  • Improved Readability: By reducing scattered try-catch blocks, catch() keeps the codebase clean and maintainable.
  • Seamless Integration: It works well with other Flow operators, enabling robust, composable, and resilient data pipelines.

7. Conclusion

In this article, we explored the catch() operator of the Kotlin Flow, highlighting its role in managing exceptions gracefully without disrupting data processing. We also demonstrated how to log errors, emit fallback values, and handle specific exceptions while maintaining clean and readable code.

The catch() operator enhances error handling and integrates seamlessly with other Flow operators like retry(), making it indispensable for building resilient, fault-tolerant applications. It also provides uninterrupted data flows and maintains robust application performance.

The code backing this article is available on GitHub. Once you're logged in as a Baeldung Pro Member, start learning and coding on the project.