How do failures work in Combine, what does Never mean, and how do catch, replaceError, retry and mapError differ?
Predict first
A tryMap fails on the value 2 of 1, 2, 3. What does each recovery produce?
import Combine
enum E: Error { case bad }
var bag = Set<AnyCancellable>()
let base = [1, 2, 3].publisher.tryMap { v -> Int in if v == 2 { throw E.bad }; return v }
var rp: [Int] = [], cat: [Int] = []
base.replaceError(with: -1).sink { rp.append($0) }.store(in: &bag)
base.catch { _ in Just(99) }.sink { cat.append($0) }.store(in: &bag)
print(rp, cat)
- [1, -1, 3] [1, 99, 3]
- [1, -1] [1, 99]
- [-1] [99]
Predict first
A request that fails twice then succeeds is wrapped in retry(3). What happens?
import Combine
enum E: Error { case bad }
var bag = Set<AnyCancellable>()
var attempts = 0
let flaky = Deferred { Future<Int, E> { p in attempts += 1; attempts < 3 ? p(.failure(.bad)) : p(.success(7)) } }
var rt: [Int] = []
flaky.retry(3).sink(receiveCompletion: { _ in }, receiveValue: { rt.append($0) }).store(in: &bag)
print(rt, attempts)
- [7] 3
- [] 1
- [7] 1
Mental model. An error in a pipeline is a break in a pipe: everything downstream stops flowing. catch reroutes to a spare pipe, replaceError caps it with one last drop, retry tries the same pipe again.
Every publisher has a Failure type. Never means “cannot fail”, and it unlocks assign and the short sink { } without a completion handler. An error is terminal: when a publisher emits .failure, no further values arrive and the whole pipeline downstream ends. Failure types must match when publishers combine, so convert with setFailureType(to:) (a Never publisher into a failing one), mapError (change the error type) and tryMap (a map that can throw, which makes the failure type Error). The recovery operators: replaceError(with:) turns a failure into one last fallback value, then finishes (so [1, tryMap-fails-on-2, 3] with replaceError(with: -1) yields 1, −1 and never 3); catch replaces the failed upstream with a new publisher of your choice (.catch { _ in Just(99) } gives 1, 99); retry(n) re-subscribes to the upstream up to n more times on failure (it only helps for cold publishers that redo their work, such as a request in Deferred); and assertNoFailure traps if one occurs.
The central design rule: an error that reaches the end of a long-lived pipeline kills it. For a pipeline that must keep running (a search box), handle errors inside the inner publisher, before it is flattened: .flatMap { query in api.search(query).replaceError(with: []) } (or catch), so one failed request does not end the stream of future queries. Use Result-valued outputs when the consumer needs to know about each failure (.map(Result.success).catch { Just(.failure($0)) }). Keep the specific error type with mapError at the boundary, and do not swallow errors with replaceError if the UI should show them. In async code, a throws function plus do/catch in a loop plays the same role, and one failure ends a for try await loop in the same way.
The failure ends the upstream, so 3 is never produced. Each operator adds one replacement value and finishes.
retry re-subscribes to the cold upstream, which runs the work again until it succeeds on the third attempt.
Common traps
- Letting one failed request end a search pipeline. Catch inside the
flatMap, not after it. - Using
retryon a hot or already-evaluated publisher. It re-subscribes but there is nothing new to run.
They might follow up with
- What does
replaceErrordo to the values after the failure point? They are lost: the pipeline finishes after the replacement value. - How do you pass a
Neverpublisher where a failing one is needed?setFailureType(to:).
Related questions
- 11.2.2 How do flatMap and switchToLatest differ, and when do you use each?
- 11.6.1 When do you use Combine, AsyncSequence or the Observation framework, and when should you not use Combine?
- 11.5.1 What is AnyCancellable, how long does a subscription live, and how do you manage it in a view model?
← What is AnyCancellable, how long does a subscription live, and how do…