Skip to Content
Course content

88: Error Handling in Combine Pipelines

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Imagine you're managing a high-speed conveyor belt in a bottling plant. Everything is moving perfectly until a single bottle cracks. In a naive system, that one broken bottle triggers an emergency stop, the belt freezes, and the entire production line shuts down. To get it moving again, you have to manually reset the whole system from scratch.

That's exactly how Combine handles errors by default. When a publisher emits a .failure completion, the pipeline isn't just "paused"—it's dead. The subscription is cancelled, and no more values will ever flow through that specific stream. If you're fetching a user's profile picture and the network blips, you don't want your entire image-loading logic to stop working for the rest of the app's lifecycle. You want a way to "swap out" the broken bottle and keep the belt moving.

The Terminal Nature of Failures

I've seen a lot of developers get tripped up here. They'll set up a search bar that triggers a network request on every keystroke. The first time the API returns a 404 or a timeout, the pipeline terminates. The user keeps typing, but nothing happens. Why? Because the pipeline is gone.

// This pipeline is fragile. One error and it's over.
let searchPublisher = $searchText
    .flatMap { query in
        URLSession.shared.dataTaskPublisher(for: URL(string: "https://api.example.com/search?q=\(query)")!)
            .map(\.data)
            .decode(type: [Result].self, decoder: JSONDecoder())
    }
    .sink(receiveCompletion: { completion in
        if case .failure(let error) = completion {
            print("Pipeline died: \(error)")
        }
    }, receiveValue: { results in
        self.results = results
    })

Providing a Safety Net with replaceError

If you don't need a complex recovery strategy and just want a "fallback" value, replaceError(with:) is your best friend. It catches any error and replaces it with a value you provide, then finishes the stream normally (sending a .finished completion instead of a .failure).

Think of this as having a backup bottle ready to go. If the main one breaks, you just slide the backup in and keep moving.

// Now, if the network fails, we just show an empty list instead of killing the stream.
let searchPublisher = $searchText
    .flatMap { query in
        URLSession.shared.dataTaskPublisher(for: URL(string: "https://api.example.com/search?q=\(query)")!)
            .map(\.data)
            .decode(type: [Result].self, decoder: JSONDecoder())
            .replaceError(with: []) // Fallback to empty array
    }
    .sink { self.results = $0 }

Surgical Recovery using catch

Sometimes a static value isn't enough. Maybe you want to try a different API endpoint or load data from a local cache if the network is down. This is where catch comes in. Unlike replaceError, catch allows you to return a completely new publisher.

I usually use this when I have a "primary" and "secondary" data source. It's like saying, "If the main conveyor belt breaks, immediately divert the flow to the backup belt."

.catch { error in
    print("Network failed, switching to local cache...")
    return LocalCache.publisher(for: query) 
}

One critical detail: the catch must be placed inside the flatMap. If you put it outside, the error from the inner publisher will still propagate up and kill the main pipeline. By catching the error inside the flatMap, you're handling the failure of the request, not the failure of the stream.

Giving it Another Shot with retry

In the real world, network errors are often transient. A momentary drop in Wi-Fi shouldn't be fatal. The retry(n) operator tells Combine: "If this fails, try the whole sequence again, up to n times, before finally giving up."

I generally recommend a low number here—maybe 2 or 3. You don't want to hammer a server that's genuinely down, but a quick retry can often hide those annoying "jitter" errors from the user entirely.

URLSession.shared.dataTaskPublisher(for: url)
    .retry(3) // Try 3 more times before throwing the error downstream
    .map(\.data)
    .decode(type: User.self, decoder: JSONDecoder())



📋 Practical Task

Exercise: Building a Resilient Weather Dashboard Feed

You are building a weather app that fetches current temperatures. The API is notoriously flaky. Your goal is to create a Combine pipeline that ensures the UI always has something to display, even if the network fails.

Requirements:

  • Create a publisher that simulates a network request (you can use Fail(error: WeatherError.networkIssue).eraseToAnyPublisher() to simulate a failure).
  • Implement a retry mechanism that attempts the request 2 times before failing.
  • Use the catch operator to switch to a "fallback" publisher that emits a hardcoded cached temperature (e.g., 20.0°C) if the retries also fail.
  • Ensure that the pipeline does not terminate, allowing subsequent triggers (like a refresh button) to start the process over again.
  • Print the final temperature received in the sink.

Bonus: Wrap the logic in a function that takes a City string and returns a publisher, ensuring the error handling happens inside the stream logic so the subscriber never receives a failure completion.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.