Chapter 18: Error handling and testing
The introduction explains why robust concurrent Kotlin applications need graceful error handling and testing when components or external services fail.
After this chapter you will be able to
- Robustness in concurrent applications. Explain why concurrent applications need proper error handling, and identify testing as another part of building robust software.
- Coroutine boundary and catch placement. Explain why try-catch around a launch or async invocation does not catch an exception thrown inside the new coroutine, and describe where to put try-catch to handle an exception inside launch.
- Async await and parent propagation. Describe how await handles an exception from an async coroutine and why catching it at await can still leave an error reported by the parent coroutine.
- Child failure and parent fate. Distinguish concurrent decomposition from supervision by explaining whether one child’s failure should also fail the parent and affect other children.
- Job type and failure behavior. Explain how a regular Job and a SupervisorJob in the parent coroutine’s context correspond to the two ways of handling child failures.
- Child failure propagation. Trace what happens when a child coroutine fails with an uncaught exception under a regular, nonsupervisor parent, including the effect on siblings and the parent.
- Exception handling within coroutine boundaries. Explain why catching an exception inside a coroutine prevents it from triggering exception propagation up the coroutine hierarchy and cancellation of sibling coroutines. Describe what happens to the heartbeat coroutine in the example.
- SupervisorJob failure isolation. Explain how a supervisor responds when a child coroutine fails, and distinguish that response from ordinary cancellation.
- Exception propagation and handler placement. Trace an uncaught exception from a child coroutine to a supervisor or the root of its hierarchy, and explain why a handler installed on an intermediate coroutine is not invoked.
- Custom and default exception handling. Describe how to supply a CoroutineExceptionHandler and what happens when none is present, including the JVM and Android defaults described here.
- Topmost coroutine and exception handling. Explain how choosing launch or async for the topmost coroutine under a supervisor affects whether a CoroutineExceptionHandler is invoked, and how the consumer handles an async exception.
- Flow exceptions at collection. Explain that a flow may emit values before throwing an exception and that exceptions arising during its creation, transformation, or collection are thrown from collect. Describe how try-catch around collect handles such an exception even when intermediate operators are present.
- Catch operator error handling. Explain how catch processes an exception in a flow and can emit a fallback value for downstream consumption. State that its block is not called for cancellation exceptions.
- Upstream exception boundary. Explain why catch handles only exceptions thrown before it in a flow pipeline. Describe how to position catch for an onEach error, and how an error inside a collect lambda can instead be handled.
- Retry behavior after upstream exceptions. Explain how retry handles an upstream flow exception: its lambda examines the exception and returns a Boolean, and a true result initiates another collection, subject to the maximum number of retries.
- Coroutine test timing. Explain why tests for suspending functions, coroutines, or flows may use runTest rather than runBlocking: runBlocking can run tests, but it waits out specified delays in real time, making a growing test suite slow; virtual time addresses that problem.
- Virtual time in runTest. Explain why a test containing a long coroutine delay can finish quickly under runTest and identify the test dispatcher and scheduler that enable it.
- Single-threaded test scheduling. Explain why a launched child coroutine may not run before an assertion in a runTest block and how a suspension point lets it run.
- Virtual scheduler controls. Distinguish moving the virtual clock with delay from running currently scheduled coroutines with runCurrent and running all scheduled coroutines with advanceUntilIdle.
- Turbine flow test function. Explain why Turbine helps with more complex flow tests and describe how its test function collects a flow and lets a test assert on items, completion, or errors.
What it builds on
- Handling exceptions and recovering from errors: try, catch, and finally (chapter 2) · Basic use of try-catch to handle exceptions.
- Creating start-and-forget coroutines: The launch function (chapter 14) · Using launch to create a coroutine.
- Awaitable computations: The async builder (chapter 14) · Creating an awaitable computation with async.
- From regular code into the realm of coroutines: The runBlocking function (chapter 14) · Using runBlocking as a coroutine builder.
- Coroutine scopes establish structure between coroutines (chapter 15) · The parent–child structure established by coroutine scopes.
- Coroutines carry additional information in their coroutine context (chapter 14) · A coroutine carries information in its coroutine context.
- Cancelled coroutines throw CancellationExceptions in special places (chapter 15) · Introduces the special role of CancellationExceptions in cancelled coroutines.
- Cancellation cascades through all children (chapter 15) · Cancellation cascading through child coroutines.
Section by section
Introduction
The introduction explains why robust concurrent Kotlin applications need graceful error handling and testing when components or external services fail.
- Robustness in concurrent applications: Explain why concurrent applications need proper error handling, and identify testing as another part of building robust software.
Why it matters: It gives a reason to handle failures and test concurrent code, rather than assuming every part of the system will work reliably.
Key terms: concurrent applications, error handling, testing
18.1 Handling errors thrown inside coroutines
Explains where to catch exceptions thrown in coroutines and why catching an async result at await does not stop the original exception from reaching the parent.
- Coroutine boundary and catch placement: Explain why try-catch around a launch or async invocation does not catch an exception thrown inside the new coroutine, and describe where to put try-catch to handle an exception inside launch.
Why it matters: It helps you put error handling where the exception can actually be caught. - Async await and parent propagation: Describe how await handles an exception from an async coroutine and why catching it at await can still leave an error reported by the parent coroutine.
Why it matters: Catching a failed result at await does not necessarily mean the coroutine's original failure has been handled.
Key terms: try-catch, launch, async, coroutine boundaries, await, parent coroutine, uncaught exception
18.2 Error propagation in Kotlin coroutines
This section explains when a child coroutine’s failure should bring down its parent and how a regular Job or SupervisorJob determines that behavior.
- Child failure and parent fate: Distinguish concurrent decomposition from supervision by explaining whether one child’s failure should also fail the parent and affect other children.
Why it matters: The right failure behavior depends on whether the children share a result or must be able to fail independently. - Job type and failure behavior: Explain how a regular Job and a SupervisorJob in the parent coroutine’s context correspond to the two ways of handling child failures.
Why it matters: These two context entries connect the section’s failure-handling distinction to Kotlin coroutines.
Key terms: concurrent decomposition of work, supervising execution, parent coroutine, child coroutine, regular Job, SupervisorJob, coroutine context
18.2.1 Coroutines cancel all their children when one child fails
This subsection explains how an uncaught failure in one child coroutine propagates through a regular coroutine hierarchy and cancels its siblings.
- Child failure propagation: Trace what happens when a child coroutine fails with an uncaught exception under a regular, nonsupervisor parent, including the effect on siblings and the parent.
Why it matters: It explains why one failing task can stop other tasks in the same coroutine hierarchy. - Reason for sibling cancellation: Justify why cancelling sibling coroutines is useful when coroutines in one scope cooperate to produce a common result.
Why it matters: It connects the cancellation rule to the practical cost of continuing work after a shared computation has failed.
Key terms: child coroutine, parent, sibling coroutines, uncaught exception, async, launch, scope, common result, resources
18.2.2 Structured concurrency only affects exceptions thrown across coroutine boundaries
This section explains that coroutine exception propagation and sibling cancellation apply when an uncaught exception crosses a coroutine boundary, and shows why catching it inside the coroutine avoids that behavior.
- Exception handling within coroutine boundaries: Explain why catching an exception inside a coroutine prevents it from triggering exception propagation up the coroutine hierarchy and cancellation of sibling coroutines. Describe what happens to the heartbeat coroutine in the example.
Why it matters: Where you catch an exception determines whether it affects other coroutines. - Cancellation exceptions during error handling: Explain how to treat cancellation exceptions and their supertypes when catching exceptions in coroutines.
Why it matters: A catch block for ordinary errors should not interfere with normal coroutine cancellation.
Key terms: uncaught exceptions, coroutine boundaries, try-catch block, sibling coroutines, cancellation exceptions, supertypes, rethrow
18.2.3 Supervisors prevent parents and siblings from being cancelled
This section explains how supervisors isolate child-coroutine failures and why they are useful for long-lived coroutine scopes.
- SupervisorJob failure isolation: Explain how a supervisor responds when a child coroutine fails, and distinguish that response from ordinary cancellation.
Why it matters: It explains why one failed child need not bring down otherwise independent work. - Supervisor scope in practice: Describe how supervisorScope changes the outcome when a launched child fails, including why the exception can still be visible.
Why it matters: It connects failure isolation to the section’s heartbeat example without implying that the exception disappears. - Long-lived supervisor placement: Justify why supervisors are often used for long-lived scopes rather than fine-grained work whose coroutines depend on one another.
Why it matters: It helps distinguish work that should survive an unrelated failure from work that should stop when its shared result is no longer possible.
Key terms: supervisor, SupervisorJob, Job, structured concurrency, supervisorScope, launch, heartbeat coroutine, CoroutineExceptionHandler, Application scope, PipelineContext
18.3 CoroutineExceptionHandler: The last resort for processing exceptions
The section explains where uncaught coroutine exceptions are handled and how a custom CoroutineExceptionHandler changes the default behavior.
- Exception propagation and handler placement: Trace an uncaught exception from a child coroutine to a supervisor or the root of its hierarchy, and explain why a handler installed on an intermediate coroutine is not invoked.
Why it matters: A handler attached to a child may look like it will catch that child’s exception, but the exception is delegated upward. - Custom and default exception handling: Describe how to supply a CoroutineExceptionHandler and what happens when none is present, including the JVM and Android defaults described here.
Why it matters: The fallback behavior determines whether an uncaught coroutine exception is logged or crashes an app.
Key terms: child coroutine, root coroutine, supervisor, CoroutineExceptionHandler, coroutine context, system-wide exception handler, SupervisorJob
18.3.1 Differences when using CoroutineExceptionHandler with launch or async
This section explains why a coroutine exception handler is invoked for a topmost launch coroutine but not a topmost async coroutine, and clarifies that this difference does not prevent cancellation.
- Topmost coroutine and exception handling: Explain how choosing launch or async for the topmost coroutine under a supervisor affects whether a CoroutineExceptionHandler is invoked, and how the consumer handles an async exception.
Why it matters: It tells you where to expect an async failure to be handled rather than assuming the exception handler will receive it. - Exception handling versus cancellation: Distinguish the absence of a CoroutineExceptionHandler invocation for a topmost async coroutine from the cancellation that its uncaught exception can still cause.
Why it matters: It prevents you from mistaking a silent exception handler for an absence of effects on other coroutines.
Key terms: CoroutineExceptionHandler, launch, async, Deferred, await(), SupervisorJob, cancellation, scope
18.4 Handling errors in flows
This section shows that errors in a flow surface when it is collected and can be handled around the call to collect.
- Flow exceptions at collection: Explain that a flow may emit values before throwing an exception and that exceptions arising during its creation, transformation, or collection are thrown from collect. Describe how try-catch around collect handles such an exception even when intermediate operators are present.
Why it matters: It tells you where to handle an error when collecting a flow, including one with transformations.
Key terms: flow, collect, try-catch, intermediate operators, CancellationException
18.4.1 Processing upstream exceptions with the catch operator
This section explains how a flow’s catch operator handles upstream exceptions and where it must be placed to handle errors in a pipeline.
- Catch operator error handling: Explain how catch processes an exception in a flow and can emit a fallback value for downstream consumption. State that its block is not called for cancellation exceptions.
Why it matters: You can turn a flow failure into a value the collector can handle without treating cancellation as an ordinary error. - Upstream exception boundary: Explain why catch handles only exceptions thrown before it in a flow pipeline. Describe how to position catch for an onEach error, and how an error inside a collect lambda can instead be handled.
Why it matters: Placing catch on the wrong side of an operation leaves that operation’s exception unhandled by it.
Key terms: catch, cancellation exceptions, emit, downstream flow, upstream, downstream, onEach, collect, try-catch
18.4.2 Retry the collection of a flow if predicate is true: The retry operator
Explains how the flow retry operator handles upstream exceptions by recollecting the flow, and why repeated upstream work matters.
- Retry behavior after upstream exceptions: Explain how retry handles an upstream flow exception: its lambda examines the exception and returns a Boolean, and a true result initiates another collection, subject to the maximum number of retries.
Why it matters: This tells you what work a retry actually repeats after a flow fails. - Repeated upstream side effects: Explain why side effects in upstream flow steps need care when using retry, and describe the section’s suggested safeguard.
Why it matters: A retry can repeat more than the failed operation, so upstream effects must tolerate repetition.
Key terms: retry operator, upstream exceptions, lambda, maximum number of retries, intermediate operations, side effects, upstream operators, idempotent
18.5 Testing coroutines and flows
This section introduces runTest for coroutine tests and explains why real-time delays make runBlocking-based tests slow, motivating virtual time.
- Coroutine test timing: Explain why tests for suspending functions, coroutines, or flows may use runTest rather than runBlocking: runBlocking can run tests, but it waits out specified delays in real time, making a growing test suite slow; virtual time addresses that problem.
Why it matters: Avoiding unnecessary waits makes a test suite quicker and less cumbersome to run.
Key terms: runTest, runBlocking, virtual time
18.5.1 Making tests using coroutines fast: Virtual time and the test dispatcher
This section explains how runTest uses virtual time to speed up coroutine tests and how to control scheduled work while making assertions.
- Virtual time in runTest: Explain why a test containing a long coroutine delay can finish quickly under runTest and identify the test dispatcher and scheduler that enable it.
Why it matters: Virtual time keeps tests with coroutine delays fast. - Single-threaded test scheduling: Explain why a launched child coroutine may not run before an assertion in a runTest block and how a suspension point lets it run.
Why it matters: An assertion can run too early unless the test gives launched coroutines a chance to execute. - Virtual scheduler controls: Distinguish moving the virtual clock with delay from running currently scheduled coroutines with runCurrent and running all scheduled coroutines with advanceUntilIdle.
Why it matters: These controls let a test reach the intended point in a coroutine's execution before checking results. - Limits of virtual time: Explain why a coroutine explicitly using a nontest dispatcher still waits out delays and how allowing a dispatcher to be changed helps keep code quickly testable.
Why it matters: A hard-coded nontest dispatcher can make a supposedly fast test wait in real time.
Key terms: runTest, virtual time, test dispatcher, scheduler, single-threaded dispatcher, suspension points, yield, TestScope, TestCoroutineScheduler, delay
18.5.2 Testing flows with Turbine
This section shows how to test finite flows by collecting their values and how Turbine supports tests of more complex flows.
- Finite flow collection tests: Describe how to test a finite flow by collecting its elements with toList and checking the resulting collection against expected elements.
Why it matters: For a finite flow, an ordinary assertion on collected values may be enough. - Turbine flow test function: Explain why Turbine helps with more complex flow tests and describe how its test function collects a flow and lets a test assert on items, completion, or errors.
Why it matters: Turbine lets you assert on a flow’s emissions and outcomes without first collecting the whole flow into a list.
Key terms: runTest, toList, finite flow, Turbine, test, awaitItem, awaitComplete, awaitError
18.6 Summary
Concepts explained in this chapter
- SupervisorJobSupervisorJob is a coroutine Job implementation that prevents failure of one child coroutine from automatically cancelling its sibling coroutines.
- supervisorA supervisor is a coroutine failure-isolation mechanism that defines a boundary within which child coroutine failures do not automatically cancel sibling coroutines or the supervising scope.
- virtual timeVirtual time is a test-controlled representation of elapsed time that allows time-dependent operations to advance without waiting for real time to pass.
- runTestrunTest is a kotlinx.coroutines testing function that runs coroutine-based tests with controlled virtual time and test scheduling.
- test dispatcherA test dispatcher is a coroutine dispatcher that schedules test coroutines, typically under controlled and often single-threaded execution.
- CoroutineExceptionHandlerCoroutineExceptionHandler is a coroutine context element that handles uncaught exceptions from root coroutines launched with launch.
- TurbineTurbine is a testing library for inspecting emissions from Kotlin Flow and other asynchronous streams.
- child coroutineA child coroutine is a coroutine launched within the scope of another coroutine and associated with that parent coroutine.
- test coroutine schedulerA TestCoroutineScheduler is a scheduler that tracks and controls coroutine execution and virtual time in coroutine tests.
- asyncasync is a coroutine builder that starts an asynchronous computation and produces a handle for obtaining its result.
- launchlaunch is a kotlinx.coroutines coroutine builder that starts a coroutine whose computation does not produce a result value.
- coroutine contextcoroutineContext is a compiler-provided property that exposes the context of the currently executing coroutine from a suspending function.
- supervisorScopesupervisorScope is a kotlinx.coroutines suspending function that creates a coroutine scope with supervisor semantics for its child coroutines.
- try-catch statementA try-catch is a Kotlin exception-handling construct that executes a protected block and handles matching exceptions in catch clauses.
- retryA retry is a subsequent attempt to perform an operation after an earlier attempt fails or does not complete successfully.
- error propagationError propagation is the transmission of an error or exception from the computation where it occurs to an enclosing or related computation.
- exceptionException is the Java base class for conditions that a program may catch and handle, excluding serious errors represented by Error.
- coroutine hierarchyA coroutine hierarchy is a parent-child organization of coroutines through which execution lifetimes and failures can be related.
- futureA future is an abstraction representing a result that may become available after an asynchronous computation finishes.
- async/awaitawait is an operation that suspends a coroutine until an asynchronous computation's result is available.
- task cancellationTask cancellation is a request or mechanism for stopping an asynchronous or in-progress operation before it completes normally.
- suspension pointA suspension point is a point at which a suspending coroutine can pause and allow other coroutines sharing its dispatcher to execute.
- structured concurrencyStructured concurrency is a concurrency model that scopes concurrent tasks so their lifetimes and failures are managed through a shared parent-child hierarchy.
- coroutine builder functionA coroutine builder function is an API function that creates and starts a coroutine with specified execution, context, and result behavior.
- coroutineA coroutine is a lightweight suspendable computation that can pause its execution and resume it later without blocking its underlying thread.
- JobJob is a coroutine-context element that represents the lifecycle of a coroutine and propagates child failure to its parent under regular structured concurrency.
- collectcollect is a Kotlin Flow terminal operation that suspends while collecting all emitted elements and optionally processes each element with a lambda.
- CancellationExceptionCancellationException is an exception used by Kotlin coroutines to signal that a coroutine has been cancelled.
These guides are generated from the text of Manning books by Manning's knowledge graph and link to the sections they draw on. The full books are available on liveBook and at manning.com.
