Cloudy shows the user an error if it isn't able to fetch weather data from the weather API. We map any errors that are thrown to WeatherDataError in the RootViewModel class. This solution works fine and it is a fitting implementation for the weather application we are building. In this episode, I show you three other options to handle errors.

Catching Errors

In the previous episode, we used the tryMap operator to emit an error if the response of the request for weather data doesn't meet Cloudy's requirements. The tryMap operator works as advertised, but it doesn't allow the view model to intercept errors emitted by the upstream publisher. Because a subscription is terminated as soon as an error is emitted, you sometimes want to catch the error instead of propagating it downstream. The catch operator does exactly that. Let me show you how it works.

Open the starter project of this episode in Xcode and navigate to WeatherServiceRequest.swift. The WeatherServiceRequest struct declares a property, baseUrl, that defines the base URL of the primary weather service Cloudy uses to fetch weather data. It also declares a property, fallbackBaseUrl, that defines the base URL of a secondary weather service.

struct WeatherServiceRequest {

    // MARK: - Properties

    private let apiKey = "..."
    private let baseUrl = URL(string: "https://cocoacasts.com/clearsky/")!
    private let fallbackBaseUrl = URL(string: "https://cocoacasts.com/darksky/")!

    ...

}

The url property returns the URL for a request to the primary weather service whereas fallbackUrl returns the URL for a request to the secondary weather service.

// MARK: - Public API

var url: URL {
    url(for: baseUrl)
}

var fallbackUrl: URL {
    url(for: fallbackBaseUrl)
}

// MARK: - Helper Methods

private func url(for baseUrl: URL) -> URL {
    // Create URL Components
    guard var components = URLComponents(url: baseUrl, resolvingAgainstBaseURL: false) else {
        fatalError("Unable to Create URL Components for Weather Service Request")
    }

    // Define Query Items
    components.queryItems = [
        URLQueryItem(name: "api_key", value: apiKey),
        URLQueryItem(name: "lat", value: "\(latitude)"),
        URLQueryItem(name: "long", value: "\(longitude)")
    ]

    guard let url = components.url else {
        fatalError("Unable to Create URL for Weather Service Request")
    }

    return url
}

In summary, we can ask a WeatherServiceRequest object for the URL for the primary weather service and we can ask it for the URL for the secondary weather service.

Revisit RootViewModel.swift and navigate to the fetchWeatherData(for:) method. We first store the WeatherServiceRequest object in a constant with name request and access the URL for the primary weather service through the object's url property.

// Create Weather Service Request
let request = WeatherServiceRequest(latitude: latitude, longitude: longitude)

// Create Data Task Publisher
URLSession.shared.dataTaskPublisher(for: request.url)

We apply the catch operator to the data task publisher. The closure that is passed to the catch operator is only executed if the upstream publisher, the data task publisher, emits a failure. The closure accepts the upstream failure as an argument and returns a publisher. In the closure, we create another data task publisher. This time, we pass the URL for a request to the secondary weather service to the dataTaskPublisher(for:) method.

// Create Weather Service Request
let request = WeatherServiceRequest(latitude: latitude, longitude: longitude)

// Create Data Task Publisher
URLSession.shared.dataTaskPublisher(for: request.url)
    .catch { _ in
        URLSession.shared.dataTaskPublisher(for: request.fallbackUrl)
    }

What is the result of the change we made? The root view model asks the default weather service for data. If that request fails, Combine intercepts the failure the data task publisher emits and sends a request to the fallback weather service.

When would this happen? Let's assume the weather service is down for maintenance. To make sure the users of the application aren't impacted by the outage, Cloudy automatically switches to the secondary weather service if it detects a problem with the primary weather service.

While this isn't necessary for an application like Cloudy, it may be critical for other applications. Combine makes this behavior trivial to implement. If you were to implement a similar solution without the help of a reactive framework, it would require a lot more code and it wouldn't be as elegant and readable.

Replacing Errors

There is another operator we can use to recover from an error. The catch operator catches an error and replaces the upstream publisher with another publisher. The replaceError operator makes it possible to replace an error with an element. The operator accepts a single argument, the element that replaces the error. As you may have guessed, the type of the argument is the Output type of the upstream publisher.

When the replaceError operator is used to recover from an error, the downstream subscriber is unaware of the upstream failure. The subscriber receives an element followed by a completion event. You could say that the upstream publisher fails silently.

Consider the following scenario. The root view model caches the last successful response in memory. If the next request for weather data fails, the replaceError operator returns the response of that last successful response. Whether this is a viable solution to recover from an error depends on the type of data the application requests.

Catching Errors or Replacing Errors

It is important to understand that catching an error and replacing it with another publisher doesn't result in the termination of the subscription. The upstream publisher that terminated with an error is replaced with another publisher.

This isn't true if we apply the replaceError operator. Replacing an upstream failure with an element does result in the termination of the subscription. Remember that an upstream publisher is guaranteed to not emit another event after emitting an error. Because the upstream failure is replaced with an element instead of another publisher, the subscriber receives the element and a completion event.

Retrying Requests

The cause of a failed request may be transient and it is therefore common to retry the request once or twice. Combine makes this trivial with the retry operator. We apply the operator to the data task publisher. The retry(_:) method accepts the maximum number of retries as its only argument. In this example, the request is retried a maximum of three times.

// Create Data Task Publisher
URLSession.shared.dataTaskPublisher(for: request.url)
    .retry(3)

It is that simple to retry a request using Combine. Implementing the same solution without Combine would require a lot more code and it would also involve managing state. Even though retrying a request requires a single line of code, I want to make sure you understand what happens under the hood.

The retry operator kicks into action the moment the upstream publisher terminates with a failure. When that happens, two events take place. First, the upstream publisher terminates with a failure. The retry operator cannot and should not change that. Second, the retry operator recreates the subscription to the publisher that terminated with a failure, the data task publisher in this example. This simply means that we start with a clean slate.

If the request still fails after three retries, the retry operator forwards the failure of the upstream publisher to the downstream subscriber, terminating the subscription.

What's Next?

Error handling isn't complicated once you understand how the pieces of the puzzle fit together. Always keep the basics in mind. A publisher can emit values and errors. Once a publisher emits an error, it is guaranteed to not emit any other events. The subscription is terminated the moment an error is emitted.

Apple did a good job naming the operators of the Combine framework. Most operators have sensible names and it doesn't take long to understand how to use catch, tryMap, and replaceError.