Home

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.