Where should a catch go in a search pipeline, and what happens to the stream if you put it outside the flatMap?
Predict first
Which pipeline survives the failing query?
import Combine
var bag = Set<AnyCancellable>()
struct Bad: Error {}
func request(_ q: String) -> AnyPublisher<String, Bad> {
q == "bad" ? Fail(error: Bad()).eraseToAnyPublisher()
: Just("result(\(q))").setFailureType(to: Bad.self).eraseToAnyPublisher()
}
let queries = PassthroughSubject<String, Never>()
let outside = queries.setFailureType(to: Bad.self).flatMap { request($0) }.catch { _ in Just("recovered") }
outside.sink(receiveCompletion: { print("outside completion", $0) }, receiveValue: { print("outside", $0) }).store(in: &bag)
let inside = queries.flatMap { q in request(q).catch { _ in Just("recovered") } }
inside.sink(receiveCompletion: { print("inside completion", $0) }, receiveValue: { print("inside", $0) }).store(in: &bag)
queries.send("one"); queries.send("bad"); queries.send("two")
- outside result(one) ... outside recovered, outside completion finished; inside keeps going and prints result(two)
- both pipelines print result(two)
- neither pipeline recovers
Mental model. A factory conveyor with one bad item. Catching the problem at each workstation rejects that item and keeps the belt moving; catching it at the end of the line stops the whole belt.
A failure is terminal: once a publisher fails, the stream is over. catch replaces the failed publisher with another one, and whatever it replaces is what ends. If you put catch after the flatMap, the failing inner request fails the whole flatMap, so the catch swaps in Just("recovered"), which emits once and then completes: the check prints “outside recovered” and “outside completion finished”, and the later query two is never handled. The pipeline is dead after the first error, the classic “search stopped working after one network failure” bug.
Put the catch inside the flatMap, on the inner request: the failing inner publisher is replaced with a value, the inner publisher completes normally (which flatMap treats as that request being finished) and the outer stream, still subscribed to the query subject, handles two as usual (“inside result(two)”). The rule: handle errors at the lowest level where you can substitute a value, so the long-lived outer stream never sees a failure; the outer stream should then have Failure == Never (which also lets you use assign and sink(receiveValue:) freely, Q311). If you want to show an error and also keep going, map the inner result to a Result or an enum with an error case instead of failing.
Catching after flatMap ends the stream with the substitute; catching inside keeps the outer stream alive.
Common traps
- Putting
catchorreplaceErrorafter a long-lived outer stream. One failure ends it for good. - Letting a failure type leak into the UI pipeline. Convert errors to values early so the stream is
Never-failing.
They might follow up with
- How do I retry and also keep the outer stream alive? Apply
retryandcatchto the inner request, inside the flatMap. - Is this specific to Combine? No: the same idea applies to
AsyncStreamloops, where atryinside the loop body ends the loop unless you catch inside.
Related questions
- 11.7.2 What does flatMap(maxPublishers:) do, and why can limiting it silently lose values?
- 11.7.4 Why does retry sometimes not retry anything, and how do Future and Deferred differ here?
- 11.10.2 Why does merging or combining publishers fail to compile with “types Never and E must be equivalent”, and how…
- 11.2.2 How do flatMap and switchToLatest differ, and when do you use each?
- 11.5.2 How do failures work in Combine, what does Never mean, and how do catch, replaceError, retry and mapError…
← What does flatMap(maxPublishers:) do, and why can limiting it… Why does retry sometimes not retry anything, and how do Future and… →