Chapters

Hide chapters

Combine: Asynchronous Programming with Swift

Third Edition · iOS 15 · Swift 5.5 · Xcode 13

16. Error Handling
Written by Shai Mishali

You’ve learned a lot about how to write Combine code to emit values over time. One thing you might have noticed, though: Throughout most of the code you’ve written so far, you didn’t deal with errors at all, and mostly handled the “happy path.”

Unless you write error-free apps, this chapter is for you! :]

As you learned in Chapter 1, “Hello, Combine!,” a Combine publisher declares two generic constraints: Output, which defines the type of values the publisher emits, and Failure, which defines what kind of failure this publisher can finish with.

Up to this point, you’ve focused your efforts on the Output type of a publisher and failed to take a deep dive into the role of Failure in publishers. Well, don’t worry, this chapter will change that!

Publisher <Output, Failure> You are here!

Getting started

Open the starter playground for this chapter in projects/Starter.playground. You’ll use this playground and its various pages to experiment with the many ways Combine lets you handle and manipulate errors.

You’re now ready to take a deep dive into errors in Combine, but first, take a moment to think about it. Errors are such a broad topic, where would you even start?

Well, how about starting with the absence of errors?

Never

A publisher whose Failure is of type Never indicates that the publisher can never fail.

While this might seem a tad strange at first, it provides some extremely powerful guarantees about these publishers. A publisher with Never failure type lets you focus on consuming the publisher’s values while being absolutely sure the publisher will never fail. It can only complete successfully once it’s done.

Can only finish with a completion event: Publisher <Int,Never> It can never fail, so this can’t happen: 1 3 7 3 1 7

Open the Project Navigator in the starter playground by pressing Command-1, then select the Never playground page.

Add the following example to it:

example(of: "Never sink") {
  Just("Hello")
}

You create a Just with a string value of Hello. Just always declares a Failure of Never. To confirm this, Command-click the Just initializer and select Jump to Definition:

Looking at the definition, you can see a type alias for Just’s failure:

public typealias Failure = Never

Combine’s no-failure guarantee for Never isn’t just theoretical, but is deeply rooted in the framework and its various APIs.

Combine offers several operators that are only available when the publisher is guaranteed to never fail. The first one is a variation of sink to handle only values.

Go back to the Never playground page and update the above example so it looks like this:

example(of: "Never sink") {
  Just("Hello")
    .sink(receiveValue: { print($0) })
    .store(in: &subscriptions)
}

Run your playground and you’ll see the Just’s value printed out:

——— Example of: Never sink ———
Hello

In the above example, you use sink(receiveValue:). This specific overload of sink lets you ignore the publisher’s completion event and only deal with its emitted values.

This overload is only available for infallible publishers. Combine is smart and safe when it comes to error handling, and forces you to deal with a completion event if an error may be thrown — i.e., for a non-failing publisher.

To see this in action, you’ll want to turn your Never-failing publisher into one that may fail. There are a few ways to do this, and you’ll start with the most popular one — the setFailureType operator.

setFailureType

The first way to turn an infallible publisher into a fallible one is to use setFailureType. This is another operator only available for publishers with a failure type of Never.

Add the following code and example to your playground page:

enum MyError: Error {
  case ohNo
}

example(of: "setFailureType") {
  Just("Hello")
}

You start by defining a MyError error type outside the scope of the example. You’ll reuse this error type in a bit. You then start the example by creating a Just similar to the one you used before.

Now, you can use setFailureType to change the failure type of the publisher to MyError. Add the following line immediately after the Just:

.setFailureType(to: MyError.self)

To confirm this actually changed the publisher’s failure type, start typing .eraseToAnyPublisher(), and the auto-completion will show you the erased publisher type:

Delete the .erase... line you started typing before proceeding.

Now it’s time to use sink to consume the publisher. Add the following code immediately after your last call to setFailureType:

// 1
.sink(
  receiveCompletion: { completion in
    switch completion {
    // 2
    case .failure(.ohNo):
      print("Finished with Oh No!")
    case .finished:
      print("Finished successfully!")
    }
  },
  receiveValue: { value in
    print("Got value: \(value)")
  }
)
.store(in: &subscriptions)

You might have noticed two interesting facts about the above code:

  1. It’s using sink(receiveCompletion:receiveValue:). The sink(receiveValue:) overload is no longer available since this publisher may complete with a failure event. Combine forces you to deal with the completion event for such publishers.
  2. The failure type is strictly typed as MyError, which lets you target the .failure(.ohNo) case without unnecessary casting to deal with that specific error.

Run your playground, and you’ll see the following output:

——— Example of: setFailureType ———
Got value: Hello
Finished successfully!

Of course, setFailureType’s effect is only a type-system definition. Since the original publisher is a Just, no error is actually thrown.

You’ll learn more about how to actually produce errors from your own publishers later in this chapter. But first, there are still a few more operators that are specific to never-failing publishers.

assign(to:on:)

The assign operator you learned about in Chapter 2, “Publishers & Subscribers,” only works on publishers that cannot fail, same as setFailureType. If you think about it, it makes total sense. Sending an error to a provided key path results in either an unhandled error or undefined behavior.

Add the following example to test this:

example(of: "assign(to:on:)") {
  // 1
  class Person {
    let id = UUID()
    var name = "Unknown"
  }

  // 2
  let person = Person()
  print("1", person.name)

  Just("Shai")
    .handleEvents( // 3
      receiveCompletion: { _ in print("2", person.name) }
    )
    .assign(to: \.name, on: person) // 4
    .store(in: &subscriptions)
}

In the above piece of code, you:

  1. Define a Person class with id and name properties.
  2. Create an instance of Person and immediately print its name.
  3. Use handleEvents, which you learned about previously, to print the person’s name again once the publisher sends a completion event.
  4. Finish up by using assign to set the person’s name to whatever the publisher emits.

Run your playground and look at the debug console:

——— Example of: assign(to:on:) ———
1 Unknown
2 Shai

As expected, assign updates the person’s name as soon as Just emits its value, which works because Just cannot fail. In contrast, what do you think would happen if the publisher had a non-Never failure type?

Add the following line immediately below Just("Shai"):

.setFailureType(to: Error.self)

In this code, you’ve set the failure type to a standard Swift error. This means that instead of being a Publisher<String, Never>, it’s now a Publisher<String, Error>.

Try to run your playground. Combine is very verbose about the issue at hand:

referencing instance method 'assign(to:on:)' on 'Publisher' requires the types 'Error' and 'Never' be equivalent

Remove the call to setFailureType you just added, and make sure your playground runs with no compilation errors.

assign(to:)

There is one tricky part about assign(to:on:) — It’ll strongly capture the object provided to the on argument.

Let’s explore why this is problematic.

Add the following code immediately after the previous example:

example(of: "assign(to:)") {
  class MyViewModel: ObservableObject {
    // 1
    @Published var currentDate = Date()

    init() {
      Timer.publish(every: 1, on: .main, in: .common) // 2
        .autoconnect() 
        .prefix(3) // 3
        .assign(to: \.currentDate, on: self) // 4
        .store(in: &subscriptions)
    }
  }

  // 5
  let vm = MyViewModel()
  vm.$currentDate
    .sink(receiveValue: { print($0) })
    .store(in: &subscriptions)
}

This code is a tad long, so let’s break it down. You:

  1. Define a @Published property inside a view model object. Its initial value is the current date.
  2. Create a timer publisher which emits the current date every second.
  3. Use the prefix operator to only accept 3 date updates.
  4. Apply the assign(to:on:) operator to assign every date update to your @Published property.
  5. Instantiate your view model, sink over the published publisher, and print out every value.

If you run your playground, you’ll see output similar to the following:

——— Example of: assign(to:on:) strong capture ———
2021-08-21 12:43:32 +0000
2021-08-21 12:43:33 +0000
2021-08-21 12:43:34 +0000
2021-08-21 12:43:35 +0000

As expected, the code above prints the initial date assigned to the published property, and then 3 consecutive updated (limited by the prefix operator).

Seemingly, everything is working just fine, so what’s actually wrong here?

The call to assign(to:on:) creates a subscription that strongly retains self. Essentially — self hangs on to the subscription, and the subscription hangs on to self, creating a retain cycle resulting in a memory leak.

AnyCancellable self assign(to: currentDate, on: ) self store(in: ) &subscriptions

Fortunately, the good folks at Apple realized how problematic this is and introduced another overload of this operator - assign(to:).

This operator specifically deals with reassigning published values to a @Published property by providing an inout reference to its projected publisher.

Go back to the example code, find the following two lines:

.assign(to: \.currentDate, on: self) // 3
.store(in: &subscriptions)

And replace them with the following line:

.assign(to: &$currentDate)

Using the assign(to:) operator and passing it an inout reference to the projected publisher breaks the retain cycle and lets you easily deal with the problem presented above.

Also, it automatically takes care of memory management for the subscription internally, which lets you omit the store(in: &subscriptions) line.

Note: Before moving on, it’s recommended to comment out the previous example so the printed out timer events won’t add unnecessary noise to your console output.

You’re almost done with infallible publishers at this point. But before you start dealing with errors, there’s one final operator related to infallible publishers you should know: assertNoFailure.

assertNoFailure

The assertNoFailure operator is useful when you want to protect yourself during development and confirm a publisher can’t finish with a failure event. It doesn’t prevent a failure event from being emitted by the upstream. However, it will crash with a fatalError if it detects an error, which gives you a good incentive to fix it in development.

Add the following example to your playground:

example(of: "assertNoFailure") {
  // 1
  Just("Hello")
    .setFailureType(to: MyError.self)
    .assertNoFailure() // 2
    .sink(receiveValue: { print("Got value: \($0) ")}) // 3
    .store(in: &subscriptions)
}

In the previous code, you:

  1. Use Just to create an infallible publisher and set its failure type to MyError.
  2. Use assertNoFailure to crash with a fatalError if the publisher completes with a failure event. This turns the publisher’s failure type back to Never.
  3. Print out any received values using sink. Notice that since assertNoFailure sets the failure type back to Never, the sink(receiveValue:) overload is at your disposal again.

Run your playground, and as expected it should work with no issues:

——— Example of: assertNoFailure ———
Got value: Hello 

Now, after setFailureType, add the following line:

.tryMap { _ in throw MyError.ohNo }

You just used tryMap to throw an error once Hello is pushed downstream. You’ll learn more about try-prefixed operators later in this chapter.

Run your playground again and take a look at the console. You’ll see output similar to the following:

Playground execution failed:

error: Execution was interrupted, reason: EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0).

...

frame #0: 0x00007fff232fbbf2 Combine`Combine.Publishers.AssertNoFailure...

The playground crashes because a failure occurred in the publisher. In a way, you can think of assertFailure() as a guarding mechanism for your code. While not something you should use in production, it is extremely useful during development to “crash early and crash hard.”

Comment out the call to tryMap before moving on to the next section.

Dealing with failure

Wow, so far you’ve learned a lot about how to deal with publishers that can’t fail at all… in an error-handling chapter! :] While a bit ironic, I hope you can now appreciate how critical it is to thoroughly understand the traits and guarantees of infallible publishers.

With that in mind, it’s time for you to learn about some techniques and tools Combine provides to deal with publishers that actually fail. This includes both built-in publishers and your own publishers!

But first, how do you actually produce failure events? As mentioned in the previous section, there are several ways to do this. You just used tryMap, so why not learn more about how these try operators work?

try* operators

In Section II, “Operators,” you learned about most of Combine’s operators and how you can use them to manipulate the values and events your publishers emit. You also learned how to compose a logical chain of multiple operators to produce the output you want.

In these chapters, you learned that most operators have parallel operators prefixed with try, and that you’ll “learn about them later in this book.” Well, later is now!

Combine provides an interesting distinction between operators that may throw errors and ones that may not.

Note: All try-prefixed operators in Combine behave the same way when it comes to errors. In the essence of time, you’ll only experiment with the tryMap operator throughout this chapter.

First, select the try operators* playground page from the Project navigator. Add the following code to it:

example(of: "tryMap") {
  // 1
  enum NameError: Error {
    case tooShort(String)
    case unknown
  }

  // 2
  let names = ["Marin", "Shai", "Florent"].publisher
  
  names
    // 3
    .map { value in
      return value.count
    }
    .sink(
      receiveCompletion: { print("Completed with \($0)") },
      receiveValue: { print("Got value: \($0)") }
    )
}

In the above example, you:

  1. Define a NameError error enum, which you’ll use momentarily.
  2. Create a publisher emitting three different strings.
  3. Map each string to its length.

Run the example and check out the console output:

——— Example of: tryMap ———
Got value: 5
Got value: 4
Got value: 7
Completed with finished

All names are mapped with no issues, as expected. But then you receive a new product requirement: Your code should throw an error if it accepts a name shorter than 5 characters.

Replace the map in the above example with the following:

.map { value -> Int in
  // 1
  let length = value.count
  
  // 2
  guard length >= 5 else {
    throw NameError.tooShort(value)
  }
  
  // 3
  return value.count
}

In the above map, you check that the length of the string is greater or equal to 5. Otherwise, you try to throw an appropriate error.

However, as soon as you add the above code or attempt to run it, you’ll see that the compiler produces an error:

Invalid conversion from throwing function of type '(_) throws -> _' to non-throwing function type '(String) -> _'

Since map is a non-throwing operator, you can’t throw errors from within it. Luckily, the try* operators are made just for that purpose.

Replace map with tryMap and run your playground again. It will now compile and produce the following output (truncated):

——— Example of: tryMap ———
Got value: 5
Got value: 5
Completed with failure(...NameError.tooShort("Shai"))

Mapping errors

The differences between map and tryMap go beyond the fact that the latter allows throwing errors. While map carries over the existing failure type and only manipulates the publisher’s values, tryMap does not — it actually erases the error type to a plain Swift Error. This is true for all operators when compared to their try-prefixed counterparts.

Switch to the Mapping errors playground page and add the following code to it:

example(of: "map vs tryMap") {
  // 1
  enum NameError: Error {
    case tooShort(String)
    case unknown
  }

  // 2
  Just("Hello")
    .setFailureType(to: NameError.self) // 3
    .map { $0 + " World!" } // 4
    .sink(
      receiveCompletion: { completion in
        // 5
        switch completion {
        case .finished:
          print("Done!")
        case .failure(.tooShort(let name)):
          print("\(name) is too short!")
        case .failure(.unknown):
          print("An unknown name error occurred")
        }
      },
      receiveValue: { print("Got value \($0)") }
    )
    .store(in: &subscriptions)
}

In the above example, you:

  1. Define a NameError to use for this example.
  2. Create a Just which only emits the string Hello.
  3. Use setFailureType to set the failure type to NameError.
  4. Append another string to the published string using map.
  5. Finally, use sink’s receiveCompletion to print out an appropriate message for every failure case of NameError.

Run the playground and you’ll see the following output:

——— Example of: map vs tryMap ———
Got value Hello World!
Done!

Next, find the switch completion { line and Option-click on completion:

Notice that the Completion’s failure type is NameError, which is exactly what you want. The setFailureType operator lets you specifically target NameError failures such as failure(.tooShort(let name)).

Next, change map to tryMap. You’ll immediately notice the playground no longer compiles. Option-click on completion again:

Very interesting! tryMap erased your strictly-typed error and replaced it with a general Swift.Error type. This happens even though you didn’t actually throw an error from within tryMap — you simply used it! Why is that?

The reasoning is quite simple when you think about it: Swift doesn’t support typed throws yet, even though discussions around this topic have been taking place in Swift Evolution since 2015. This means when you use try-prefixed operators, your error type will always be erased to the most common ancestor: Swift.Error.

So, what can you do about it? The entire point of a strictly-typed Failure for publishers is to let you deal with — in this example — NameError specifically, and not any other kind of error.

A naive approach would be to cast the generic error manually to a specific error type, but that’s quite suboptimal. It breaks the entire purpose of having strictly-typed errors. Luckily, Combine provides a great solution to this problem, called mapError.

Immediately after the call to tryMap, add the following line:

.mapError { $0 as? NameError ?? .unknown }

mapError receives any error thrown from the upstream publisher and lets you map it to any error you want. In this case, you can utilize it to cast the error back to a NameError or fall back to a NameError.unknown error. You must provide a fallback error in this case, because the cast could theoretically fail — even though it won’t here — and you have to return a NameError from this operator.

This restores Failure to its original type and turns your publisher back to a Publisher<String, NameError>.

Build and run the playground. It should finally compile and work as expected:

——— Example of: map vs tryMap ———
Got value Hello World!
Done!

Finally, replace the entire call to tryMap with:

.tryMap { throw NameError.tooShort($0) }

This call will immediately throw an error from within the tryMap. Check out the console output once again, and make sure you get the properly-typed NameError:

——— Example of: map vs tryMap ———
Hello is too short!

Designing your fallible APIs

When constructing your own Combine-based code and APIs, you’ll often use APIs from other sources that return publishers that fail with various types. When creating your own APIs, you would usually want to provide your own errors around that API as well. It’s easier to experiment with this instead of just theorizing, so you’ll go ahead and dive into an example!

In this section, you’ll build a quick API that lets you fetch somewhat-funny dad jokes from the icanhazdadjoke API, available at https://icanhazdadjoke.com/api.

Start by switching to the Designing your fallible APIs playground page and add the following code to it, which makes up the first portion of the next example:

example(of: "Joke API") {
  class DadJokes {
    // 1
    struct Joke: Codable {
      let id: String
      let joke: String
    }

    // 2
    func getJoke(id: String) -> AnyPublisher<Joke, Error> {
      let url = URL(string: "https://icanhazdadjoke.com/j/\(id)")!
      var request = URLRequest(url: url)
      request.allHTTPHeaderFields = ["Accept": "application/json"]
      
      // 3
      return URLSession.shared
        .dataTaskPublisher(for: request)
        .map(\.data)
        .decode(type: Joke.self, decoder: JSONDecoder())
        .eraseToAnyPublisher()
    }
  }
}

In the above code, you created the shell of your new DadJokes class by:

  1. Defining a Joke struct. The API response will be decoded into an instance of Joke.
  2. Providing a getJoke(id:) method, which currently returns a publisher that emits a Joke and can fail with a standard Swift.Error.
  3. Using URLSession.dataTaskPublisher(for:) to call the icanhazdadjoke API and decode the resulting data into a Joke using a JSONDecoder and the decode operator. You might remember this technique from Chapter 9, “Networking.”

Finally, you’ll want to actually use your new API. Add the following directly below the DadJokes class, while still in the scope of the example:

// 4
let api = DadJokes()
let jokeID = "9prWnjyImyd"
let badJokeID = "123456"

// 5
api
  .getJoke(id: jokeID)
  .sink(receiveCompletion: { print($0) },
        receiveValue: { print("Got joke: \($0)") })
  .store(in: &subscriptions)

In this code, you:

  1. Create an instance of DadJokes and define two constants with valid and invalid joke IDs.
  2. Call DadJokes.getJoke(id:) with the valid joke ID and print any completion event or the decoded joke itself.

Run your playground and look at the console:

——— Example of: Joke API ———
Got joke: Joke(id: "9prWnjyImyd", joke: "Why do bears have hairy coats? Fur protection.")
finished

A polar bear on this book’s cover and a bear joke inside? Ah, classic.

So your API currently deals with the happy path perfectly, but this is an error-handling chapter. When wrapping other publishers, you need to ask yourself: “What kinds of errors can result from this specific publisher?”

In this case:

  • Calling dataTaskPublisher can fail with a URLError for various reasons, such as a bad connection or an invalid request.
  • The provided joke ID might not exist.
  • Decoding the JSON response might fail if the API response changes or its structure is incorrect.
  • Any other unknown error! Errors are plenty and random, so it’s impossible to think of every edge case. For this reason, you always want to have a case to cover an unknown or unhandled error.

With this list in mind, add the following piece of code inside the DadJokes class, immediately below the Joke struct:

enum Error: Swift.Error, CustomStringConvertible {
  // 1
  case network
  case jokeDoesntExist(id: String)
  case parsing
  case unknown
  
  // 2
  var description: String {
    switch self {
    case .network:
      return "Request to API Server failed"
    case .parsing:
      return "Failed parsing response from server"
    case .jokeDoesntExist(let id):
      return "Joke with ID \(id) doesn't exist"
    case .unknown:
      return "An unknown error occurred"
    }
  }
}

This error definition:

  1. Outlines all the possible errors that can occur in the DadJokes API.
  2. Conforms to CustomStringConvertible, which lets you provide a friendly description for each error case.

After adding the above Error type, your playground won’t compile anymore. This is because getJoke(id:) returns a AnyPublisher<Joke, Error>. Before, Error referred to Swift.Error, but now it refers to DadJokes.Error — which is actually what you want, in this case.

So, how can you take the various possible and differently-typed errors and map them all into your DadJoke.Error? If you’ve been following this chapter, you’ve probably guessed the answer: mapError is your friend here.

Add the following to getJoke(id:), between the calls to decode and eraseToAnyPublisher():

.mapError { error -> DadJokes.Error in
  switch error {
  case is URLError:
    return .network
  case is DecodingError:
    return .parsing
  default:
    return .unknown
  }
}

That’s it! This simple mapError uses a switch statement to replace any kind of error the publisher may throw with a DadJokes.Error. You might ask yourself: “Why should I wrap these errors?” The answer to this is two-fold:

  1. Your publisher is now guaranteed to only fail with a DadJokes.Error, which is useful when consuming the API and dealing with its possible errors. You know exactly what you’ll get from the type system.

  2. You don’t leak the implementation details of your API. Think about it, does the consumer of your API care if you use URLSession to perform a network request and a JSONDecoder to decode the response? Obviously not! The consumer only cares about what your API itself defines as errors — not about its internal dependencies.

There’s still one more error you haven’t dealt with: a non-existent joke ID. Try replacing the following line:

.getJoke(id: jokeID)

With:

.getJoke(id: badJokeID)

Run the playground again. This time, you’ll get the following error:

failure(Failed parsing response from server)

Interestingly enough, icanhazdadjoke’s API doesn’t fail with an HTTP code of 404 (Not Found) when you send a non-existent ID — as would be expected of most APIs. Instead, it sends back a different but valid JSON response:

{
    message = "Joke with id \"123456\" not found";
    status = 404;
}

Dealing with this case requires a bit of hackery, but it’s definitely nothing you can’t handle!

Back in getJoke(id:), replace the call to map(\.data) with the following code:

.tryMap { data, _ -> Data in
  // 6
  guard let obj = try? JSONSerialization.jsonObject(with: data),
        let dict = obj as? [String: Any],
        dict["status"] as? Int == 404 else {
    return data
  }
  
  // 7
  throw DadJokes.Error.jokeDoesntExist(id: id)
}

In the above code, you use tryMap to perform additional validation before passing the raw data to the decode operator:

  1. You use JSONSerialization to try and check if a status field exists and has a value of 404 — i.e., the joke doesn’t exist. If that’s not the case, you simply return the data so it’s pushed downstream to the decode operator.
  2. If you do find a 404 status code, you throw a .jokeDoesntExist(id:) error.

Run your playground again and you’ll notice another tiny nitpick you need to solve:

——— Example of: Joke API ———
failure(An unknown error occurred)

The failure is actually treated as an unknown error, and not as a DadJokes.Error, because you didn’t deal with that type inside mapError.

Inside your mapError, find the following line:

return .unknown

And replace it with:

return error as? DadJokes.Error ?? .unknown

If none of the other error types match, you attempt to cast it to a DadJokes.Error before giving up and falling back to an unknown error.

Run your playground again and take a look at the console:

——— Example of: Joke API ———
failure(Joke with ID 123456 doesn't exist)

This time around, you receive the correct error, with the correct type! Awesome. :]

Before you wrap up this example, there’s one final optimization you can make in getJoke(id:).

As you might have noticed, joke IDs consist of letters and numbers. In the case of our “Bad ID”, you’ve sent only numbers. Instead of performing a network request, you can preemptively validate your ID and fail without wasting resources.

Add the following final piece of code at the beginning of getJoke(id:):

guard id.rangeOfCharacter(from: .letters) != nil else {
  return Fail<Joke, Error>(
    error: .jokeDoesntExist(id: id)
  )
  .eraseToAnyPublisher()
}

In this code, you start by making sure id contains at least one letter. If that’s not the case, you immediately return a Fail.

Fail is a special kind of publisher that lets you immediately and imperatively fail with a provided error. It’s perfect for these cases where you want to fail early based on some condition. You finish up by using eraseToAnyPublisher to get the expected AnyPublisher<Joke, DadJokes.Error> type.

That’s it! Run your example again with the invalid ID and you’ll get the same error message. However, it will post immediately and won’t perform a network request. Great success!

Before moving on, revert your call to getJoke(id:) to use jokeID instead of badJokeId.

At this point, you can validate your error logic by manually “breaking” your code. After performing each of the following actions, undo your changes so you can try the next one:

  1. When you create the URL above, add a random letter inside it to break the URL. Run the playground and you’ll see: failure(Request to API Server failed).
  2. Comment out the line that starts with request.allHttpHeaderFields and run the playground. Since the server response will no longer be JSON, but instead just be plain text, you’ll see the output: failure(Failed parsing response from server).
  3. Send a random ID to getJoke(id:), as you did before. Run the playground and you’ll get: failure(Joke with ID {your ID} doesn't exist).

And that’s it! You’ve just built your very own Combine-based, production-class API layer with its own errors. What more could a person want? :]

Catching and retrying

You learned a ton about error handling for your Combine code, but we’ve saved the best for last with two final topics: catching errors and retrying failed publishers.

The great thing about Publisher being a unified way to represent work is that you have many operators that let you do an incredible amount of work with very few lines of code.

Go ahead and dive right into the example.

Start by switching to the Catching and retrying page in the Project navigator. Expand the playground’s Sources folder and open PhotoService.swift.

It includes a PhotoService with a fetchPhoto(quality:failingTimes:) method that you’ll use in this section. PhotoService fetches a photo in either high or low quality using a custom publisher. For this example, asking for a high-quality image will always fail — so you can experiment with the various techniques to retry and catch failures as they occur.

Head back to the Catching and retrying playground page and add this bare-bones example to your playground:

let photoService = PhotoService()

example(of: "Catching and retrying") {
  photoService
    .fetchPhoto(quality: .low)
    .sink(
      receiveCompletion: { print("\($0)") },
      receiveValue: { image in
        image
        print("Got image: \(image)")
      }
    )
    .store(in: &subscriptions)
}

The above code should be familiar by now. You instantiate a PhotoService and call fetchPhoto with a .low quality. Then you use sink to print out any completion event or the fetched image.

Notice that the instantiation of photoService is outside the scope of the example so that it doesn’t get deallocated immediately.

Run your playground and wait for it to finish. You should see the following output:

——— Example of: Catching and retrying ———
Got image: <UIImage:0x600000790750 named(lq.jpg) {300, 300}>
finished

Tap the Show Result button next to the first line in receiveValue and you’ll see a beautiful low-quality picture of… well, a combine.

Next, change the quality from .low to .high and run the playground again. You’ll see the following output:

——— Example of: Catching and retrying ———
failure(Failed fetching image with high quality)

As mentioned earlier, asking for a high-quality image will fail. This is your starting point! There are a few things that you could improve here. You’ll start by retrying upon a failure.

Many times, when you request a resource or perform some computation, a failure might be a one-off occurrence resulting from a bad network connection or another unavailable resource.

In these cases, you’d usually write a big ol’ mechanism to retry different pieces of work while tracking the number of attempts and deciding what to do if all attempts fail. Fortunately, Combine makes this much, much simpler.

Like all good things in Combine, there’s an operator for that!

The retry operator accepts a number. If the publisher fails, it will resubscribe to the upstream and retry up to the number of times you specify. If all retries fail, it simply pushes the error downstream as it would without the retry operator.

It’s time for you to try this. Below the line fetchPhoto(quality: .high), add the following line:

.retry(3)

Wait, is that it?! Yup. That’s it.

You get a free retry mechanism for every piece of work wrapped in a publisher, and it’s as easy as calling this simple retry operator.

Before running your playground, add this code between the calls to fetchPhoto and retry:

.handleEvents(
  receiveSubscription: { _ in print("Trying ...") },
  receiveCompletion: {
    guard case .failure(let error) = $0 else { return }
    print("Got error: \(error)")
  }
)

This code will help you see when retries occur — it prints out the subscriptions and failures that occur in fetchPhoto.

Now you’re ready! Run your playground and wait for it to complete. You’ll see the following output:

——— Example of: Catching and retrying ———
Trying ...
Got error: Failed fetching image with high quality
Trying ...
Got error: Failed fetching image with high quality
Trying ...
Got error: Failed fetching image with high quality
Trying ...
Got error: Failed fetching image with high quality
failure(Failed fetching image with high quality)

As you can see, there are four attempts. The initial attempt, plus three retries triggered by the retry operator. Because fetching a high-quality photo constantly fails, the operator exhausts all its retry attempts and pushes the error down to sink.

Replace the following call to fetchPhoto:

.fetchPhoto(quality: .high)

With:

.fetchPhoto(quality: .high, failingTimes: 2)

The faliingTimes parameter will limit the number of times that fetching a high-quality image will fail. In this case, it will fail the first two times you call it, then succeed.

Run your playground again, and take a look at the output:

——— Example of: Catching and retrying ———
Trying ...
Got error: Failed fetching image with high quality
Trying ...
Got error: Failed fetching image with high quality
Trying ...
Got image: <UIImage:0x600001268360 named(hq.jpg) {1835, 2446}>
finished

As you can see, this time there are three attempts, the initial one plus two more retries. The method fails for the first two attempts, and then succeeds and returns this gorgeous, high-quality photo of a combine in a field:

Awesome! But there’s still one final feature you’ll improve in this service call. Your product folks asked that you fall back to a low-quality image if fetching a high-quality image fails. If fetching a low-quality image fails as well, you should fall back to a hard-coded image.

You’ll start with the latter of the two tasks. Combine includes a handy operator called replaceError(with:) that lets you fall back to a default value of the publisher’s type if an error occurs. This also changes your publisher’s Failure type to Never, since you replace every possible failure with a fallback value.

First, remove the failingTimes argument from fetchPhoto, so it constantly fails as it did before.

Then add the following line, immediately after the call to retry:

.replaceError(with: UIImage(named: "na.jpg")!)

Run your playground again and take a look at the image result this time around. After four attempts — i.e., the initial plus three retries — you fall back to a hard-coded image on disk:

Also, looking at the console output reveals what you’d expect: There are four failed attempts, followed by the hard-coded fallback image:

——— Example of: Catching and retrying ———
Trying ...
Got error: Failed fetching image with high quality
Trying ...
Got error: Failed fetching image with high quality
Trying ...
Got error: Failed fetching image with high quality
Trying ...
Got error: Failed fetching image with high quality
Got image: <UIImage:0x6000020e9200 named(na.jpg) {200, 200}>
finished

Now, for the second task and final part of this chapter: Fall back to a low-quality image if the high-quality image fails. Combine provides the perfect operator for this task, called catch. It lets you catch a failure from a publisher and recover from it with a different publisher.

To see this in action, add the following code after retry, but before replaceError(with:):

.catch { error -> PhotoService.Publisher in
  print("Failed fetching high quality, falling back to low quality")
  return photoService.fetchPhoto(quality: .low)
}

Run your playground one final time and take a look at the console:

——— Example of: Catching and retrying ———
Trying ...
Got error: Failed fetching image with high quality
Trying ...
Got error: Failed fetching image with high quality
Trying ...
Got error: Failed fetching image with high quality
Trying ...
Got error: Failed fetching image with high quality
Failed fetching high quality, falling back to low quality
Got image: <UIImage:0x60000205c480 named(lq.jpg) {300, 300}>
finished

Like before, the initial attempt plus three retries to fetch the high-quality image fail. Once the operator has exhausted all retries, catch plays its role and subscribes to photoService.fetchPhoto, requesting a low-quality image. This results in a fallback from the failed high-quality request to the successful low-quality request.

Key points

  • Publishers with a Failure type of Never are guaranteed to not emit a failure completion event.
  • Many operators only work with infallible publishers. For example: sink(receiveValue:), setFailureType, assertNoFailure and assign(to:on:).
  • The try-prefixed operators let you throw errors from within them, while non-try operators do not.
  • Since Swift doesn’t support typed throws, calling try-prefixed operators erases the publisher’s Failure to a plain Swift Error.
  • Use mapError to map a publisher’s Failure type, and unify all failure types in your publisher to a single type.
  • When creating your own API based on other publishers with their own Failure types, wrap all possible errors into your own Error type to unify them and hide your API’s implementation details.
  • You can use the retry operator to resubscribe to a failed publisher for an additional number of times.
  • replaceError(with:) is useful when you want to provide a default fallback value for your publisher, in case of failure.
  • Finally, you may use catch to replace a failed publisher with a different fallback publisher.

Where to go from here?

Congratulations on getting to the end of this chapter. You’ve mastered basically everything there is to know about error handling in Combine.

You only experimented with the tryMap operator in the try* operators section of this chapter. You can find a full list of try-prefixed operators in Apple’s official documentation at https://apple.co/3233VRB.

With your mastery of error handling, it’s time to learn about one of the lower-level, but most crucial topics in Combine: Schedulers. Continue to the next chapter to find out what schedulers are and how to use them.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.