How do failures work in Combine, what does Never mean, and how do catch, replaceError, retry and mapError differ?

11.5.2 · L3 · Interview

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 retry on a hot or already-evaluated publisher. It re-subscribes but there is nothing new to run.

They might follow up with

  • What does replaceError do to the values after the failure point? They are lost: the pipeline finishes after the replacement value.
  • How do you pass a Never publisher 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…