Failure
An error ends the stream — so where you catch it is the design
Repeating the sentence from the section introduction, because everything here follows from it: an error terminates the stream it reaches. Not the value. The stream. Once a failure passes a point in the chain, everything downstream receives a completion and the subscription is over.
That makes error handling in Combine a question about placement far more than about which operator to use.
The operators
publisher.replaceError(with: []) // recover with a value, Failure becomes Never
publisher.catch { error in Just(fallback) } // recover with another publisher
publisher.mapError { APIError.network($0) } // convert the error type
publisher.retry(3) // resubscribe on failure, up to 3 times
publisher.setFailureType(to: APIError.self) // Never to a real error type, no runtime effect
publisher.assertNoFailure() // crash on error — debug only
replaceError and catch both end the failure, and both also end the stream. The recovery value
is emitted, then .finished follows immediately. There is no version of these that lets the original
upstream carry on.
Placement is the whole thing
Consider a search field driving requests:
// Broken: one failed request kills the search field for the session
searchTerms
.map { api.search($0) }
.switchToLatest()
.catch { _ in Just([]) } // recovers once, then the stream is finished
.sink { showResults($0) }
The first network error emits an empty array, completes the stream, and every subsequent keystroke goes nowhere. No crash, no log, the feature is simply dead.
// Correct: the failure is contained inside the inner publisher
searchTerms
.map { term in
api.search(term)
.catch { _ in Just([]) } // this inner stream ends; searchTerms does not
}
.switchToLatest()
.sink { showResults($0) }
The rule: catch as close to the failing publisher as possible. An error caught inside a
flatMap or map-plus-switchToLatest closure ends only that one inner request. An error caught at
the end of the chain ends everything.
Warning
This is the mechanism behind most “it worked once then stopped” bugs. If a Combine-driven feature
stops responding after an error and never recovers, look for a catch or replaceError outside
the inner publisher rather than inside it.
Errors as values
For any long-lived stream feeding UI, the more robust design is to give up on the Failure channel
and model failure as a value:
enum LoadState<T> {
case loading
case loaded(T)
case failed(String)
}
searchTerms
.map { term in
api.search(term)
.map { LoadState.loaded($0) }
.catch { Just(LoadState.failed($0.localizedDescription)) }
.prepend(.loading)
}
.switchToLatest()
.receive(on: DispatchQueue.main)
.sink { state in render(state) }
Failure == Never on the outer stream means it cannot terminate, which means the feature cannot
silently die. The prepend(.loading) gives the UI a spinner for free, and the three cases map
directly onto the three things the screen can show.
This shape is worth defaulting to for anything driving a view. The Failure type is useful inside a
pipeline and a liability at its edge.
retry, and what it does not do
retry(3) resubscribes to the upstream on failure, up to three extra times. Two things to know
before using it:
It retries immediately. There is no backoff. Three instant retries against a server that is down is three more failures in a few milliseconds, and it is the behaviour least likely to help.
It only works on cold publishers. Resubscribing to a subject or a share()d publisher does not
re-run anything, because there is no per-subscriber work to re-run.
Backoff has to be built:
func fetchWithBackoff(_ url: URL, attempts: Int = 3) -> AnyPublisher<Data, URLError> {
URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.catch { error -> AnyPublisher<Data, URLError> in
guard attempts > 1 else {
return Fail(error: error).eraseToAnyPublisher()
}
let delay = pow(2.0, Double(4 - attempts))
return Just(())
.delay(for: .seconds(delay), scheduler: DispatchQueue.global())
.setFailureType(to: URLError.self)
.flatMap { fetchWithBackoff(url, attempts: attempts - 1) }
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
Recursion plus delay is the idiomatic shape. It is also the point where async/await starts looking
considerably more readable — a for loop with a try await Task.sleep does the same thing in six
lines, and this is exactly the kind of comparison the final chapter is about.
Do not reach for assertNoFailure
It converts a failure into a crash. In a debug build that is occasionally a useful assertion about an
invariant; shipped, it is a crash report from a network error. If a stream genuinely cannot fail,
express that in the type by using a Never-failing publisher, rather than asserting it at runtime.