Error Handling with Combine and SwiftUI
How to handle errors and expose them to the user
As developers, we tend to be a rather optimistic bunch of people. At least that’s the impression you get when looking at the code we write - we mostly focus on the happy path, and tend to spend a lot less time and effort on error handling.
Even in this series, we’ve been neglecting error handling. In fact, we’ve mostly ignored it: in the previous post, we replaced any errors with a default value, which was OK for prototyping our app, but this probably isn’t a solid strategy for any app that goes into production.
This time, let’s take a closer look at how we can handle errors appropriately!
Previously…
In case you didn’t read the previous episodes (1, 2) of this series: the use case we’re discussing is the validation logic for a sign-up form. We use Combine to validate the user’s input, and as part of this validation, the app also calls an endpoint on the app’s authentication server to check if the username the user chose is still available. The endpoint will return true
or false
depending on whether the name is still available.
Error handling strategies
Before we dive deeper into how to handle errors, let’s talk about a couple of error handling strategies and whether they are appropriate in our scenario.
Ignoring the error
This might sound like a terrible idea at first, but it’s actually a viable option when dealing with certain types of errors under specific circumstances. Here are some examples:
- The user’s device is temporarily offline or there is another reason why the app cannot reach the server.
- The server is down at the moment, but will be back up soon.
In many cases, the user can continue working offline, and the app can sync with the server once the device comes back online. Of course, this requires some sort of offline capable sync solution (like Cloud Firestore).
It is good practice to provide some user feedback to make sure users understand their data hasn’t been synchronised yet. Many apps show an icon (e.g. a cloud with an upward pointing arrow) to indicate the sync process is still in progress, or a warning sign to alert the user they need to manually trigger the sync once they’re back online.
Retrying (with exponential back-off)
In other cases, ignoring the error is not an option. Imagine the booking system for a popular event: the server might be overwhelmed by the amount of requests. In this case, we want to make sure that the system will not be thrashed by the users hitting “refresh” every couple of seconds. Instead, we want to spread out the time between retries. Using an exponential backoff strategy is both in the user’s and the system’s operator’s best interest: the operator can be sure their server will not be overwhelmed even more by users trying to get through by constantly refreshing, and the users should eventually get their booking through thanks to the app automatically retrying.
Showing an error message
Some errors require the user’s action - for example if saving a document failed. In this case, it is appropriate to show a model dialog to get the user’s attention and ask them how to proceed. For less severe errors, it might be sufficient to show a toast (an overlay that shows for a brief moment and then disappears).
Replacing the entire view with an error view
Under some circumstances, it might even be appropriate to replace the entire UI with an error UI. A well-known example for this is Chrome - if the device is offline, it will display the Chrome Dino to let users know their device is offline, and to help them spend the time until their connection restores with a fun jump-and-run game.
Showing an inline error message
This is a good option in case the data the user has provided isn’t valid. Not all input errors can be detected by a local form validation. For example, an online store might have a business rule that mandates shipments worth more than a certain amount must be shipped using a specific transport provider. It’s not always feasible to implement all of these business rules in the client app (a configurable rules engine definitely might help here), so we need to be prepared to handle these kinds of semantic errors.
Ideally, we should show those kind of errors next to the respective input field to help the user provide the correct input.
Typical error conditions and how to handle them
To give you a better understanding of how to apply this in a real world scenario, let’s add some error handling to the sign-up form we created earlier in this series. In particular, we’ll deal with the following error conditions:
- Device/network offline
- Semantic validation errors
- Response parsing errors / invalid URL
- Internal server errors
Source Code
If you want to follow along, you will find the code for this episode in the following GitHub repository: https://github.com/peterfriese/SwiftUI-Combine-Applied, in the Networking
folder. The server
subfolder contains a local server that helps us simulate all the error conditions we will cover.
Implementing a fallible network API
In the previous post, we implemented an AuthenticationService
that interfaces with an authentication server. This helps us to keep everything neatly organised and separated by concerns:
- The view (
SignUpScreen
) displays the state and takes the user’s input - The view model (
SignUpScreenViewModel
) holds the state the view displays. In turn, it uses other APIs to react to the user’s actions. In this particular app, the view model uses theAuthenticationService
to interact with the authentication server - The service (
AuthenticationService
) interacts with the authentication server. Its main responsibilities are to bring the server’s responses into a format that the client can work with. For example, it converts JSON into Swift structs, and (most relevant for this post) it handles any network-layer errors and converts them into UI-level errors that the client can better work with.
The following diagram provides an overview of how the individual types work together:
If you take a look at the code we wrote in the previous post, you will notice that the checkUserNamerAvailablePublisher
has a failure type of Never
- that means it claims there is never going to be an error.
That’s a pretty bold statement, especially given network errors are really common! We were only able to guarantee this because we replaced any errors with a return value of false
:
To turn this rather lenient implementation into something that returns meaningful error messages to the caller, we first need to change the failure type of the publisher, and stop glossing over any errors by returning false
:
We also introduced a custom error type, APIError
. This will allow us to convert any errors that might occur inside our API (be it network errors or data mapping errors) into a semantically rich error that we can handle more easily in out view model.
Calling the API and handling errors
Now that the API has a failure type, we need to update the caller as well. Once a publisher emits a failure, the pipeline will terminate - unless you capture the error. A typical approach to handling errors when using flatMap
is to combine it with a catch
operator:
Applying this strategy to the code in our view model results in the following code:
And just like that, we end up where we started! If the API emits a failure (for example, the username was too short), we catch the error (1) and replace it with false
(2) - this is exactly the behaviour we had before. Except, we wrote a lot more code…
Seems like we’re getting nowhere with this approach, so let’s take a step back and look at the requirements for our solution:
- We want to use the emitted values of the pipeline to drive the state of the submit button, and to display a warning message if the chosen username is not available.
- If the pipeline emits a failure, we want to disable the submit button, and display the error message in the error label below the username input field.
- How exactly we handle the errors will depend on the type of failure, as wel will discuss later in this post.
This means:
- we need to make sure we can receive both failures and successes
- we need to make sure the pipeline doesn’t terminate if we receive a failure
To achieve all of this, we will map the result of the checkUserNameAvailablePublisher
to a Result
type. Result
is an enum that can capture both success
and failure
states. Mapping the outcome of checkUserNameAvailablePublisher
to Result
also means the pipeline will no longer terminate in case it emits a failure.
Let’s first define a typealias for the Result
type to make our life a little easier:
To turn the result of a publisher into a Result
type, we can use the following operator that John Sundell implemented in his article The power of extensions in Swift:
This allows us to update the isUsernameAvailablePublisher
in our view model like this:
With this basic plumbing in place, let’s look at how to handle the different error scenarios I outlined earlier.
Handling Device/Network Offline Errors
On mobile devices it is pretty common to have spotty connectivity: especially when you’re on the move, you might be in an area with bad or no coverage.
Whether or not you should show an error message depends on the situation:
For our use case we can assume that the user at least has intermittent connectivity. Telling the user that we cannot reach the server would be rather distracting while they’re filling out the form. Instead, we should ignore any connectivity errors for the form validation (and instead run our local form validation logic).
Once the user has entered all their details and submits the form, we should show an error message if the device is still offline.
Catching this type of error requires us to make changes at two different places. First, in checkUserNameAvailablePublisher
, we use mapError
to catch any upstream errors and turn them into an APIError
Then, in our view model, we map the result to detect if it was a failure
(1, 2). If so, we extract the error and check if it is a network transport error. If that’s the case, we return an empty string (3) to suppress the error message:
In case isUsernameAvailablePublisher
returned a success
, we extract the Bool
telling us whether or not the desired username is available, and map this to an appropriate message.
And finally, we assign the result of the pipeline to the usernameMessage
(4) and isValid
(5) published properties which drive the UI on our view.
Keep in mind that ignoring the network error is a viable option for this kind of UI - it might be an entirely different story for you use case, so use your own judgement when applying this technique.
So far, we haven’t exposed any errors to the user, so let’s move on to a category of errors that we actually want to make the user aware of.
Handling Validation Errors
Most validation errors should be handled locally on the client, but sometimes we cannot avoid running some additional validation steps on the server. Ideally, the server should return a HTTP status code in the 4xx range, and optionally a payload that provides more details.
In our example app, the server requires a minimum username length of four characters, and we have a list of usernames that are forbiden (such as “admin” or “superuser”).
For these cases, we want to display a warning message and disable the submit button.
Our backend implementatin is based on Vapor, and will respond with a HTTP status of 400 and an error payload for any validation errors. If you’re curious about the implementation, check out the code in routes.swift
.
Handling this error scenario requires us to make changes in two places: the service implementation and the view model. Let’s take a look at the service implementation first.
Since we should handle any errors before even trying to extract the payload from the response, the code for handling server errors needs to run after checking for URLErrors and before mapping data:
Let’s take a closer look at what the code in this snippet does:
- If the response isn’t a
HTTPURLResonse
, we returnAPIError.invalidResponse
- We use Swift’s pattern matching to detect if the request was executed successfully, i.e., with a HTTP status code in the range of
200
to299
- Otherwise, some error occurred on the server. Since we use Vapor, the server will return details about the error in a JSON payload, so we can now map this information to an
APIErrorMessage
struct and use it to create more meaningful error message in the following code - If the server returns a HTTP status of
400
, we know that this is a validation error (see the server implementation for details), and return anAPIError.validationError
including the detailed error message we received from the server
In the view model, we can now use this information to tell the user that their chosen username doesn’t meet the requirements:
That’s right - just three lines of code. We’ve already done all the hard work, so it’s time to reap the benefits 🎉
Handling Response Parsing Errors
There are many situations in which the data sent by the server doesn’t match what the client expected:
- the response includes additional data, or some fieds were renamed
- the client is connecting via a captive portal (e.g. in a hotel)
In these cases, the client receives data, but it’s in the wrong format. To help the user resolve the situation, we’ll need to analyse the response and then provide suitable guidance, for example:
- download the latest version of the app
- sign in to the captive portal via the system browser
The current implementation uses the decode
operator to decode the response payload and throw an error in case the payload couldn’t be mapped. This works well, and any decoding error will be caught and show on the UI. However, an error message like The data couldn’t be read because it is missing isn’t really user friendly. Instead, let’s try to show a message that is a little bit more meaningful for users, and also suggest to upgrade to the latest version of the app (assuming the server is returning additional data that the new app will be able to leverage).
To be able to provide more fine-grained informtion about decoding errors, we need to part ways with the decode
operator and fall back to manually mapping the data (don’t worry, thanks to JSONDecoder
and Swift’s Codable
protocol, this is pretty straighforward):
By conforming APIError
to LocalizedError
and implementing the errorDescription
property, we can provide a more user-friendly error message (I included custom messages for the other error conditions as well):
Now, to make it abundandly clear to the user that they should update the app, we will also display an alert. Here is the code for the alert:
You’ll notice that the presentation state of this alert is driven by a published property on the view model, showUpdateDialog
. Let’s update the view model accordingly (1), and also add the Combine pipeline that maps the results of isUsernameAvailablePublisher
to this new property:
As you can see, nothing too fancy - we essentially just take any events coming in from the isUsernameAvailablePublisher
and convert them into a Bool
that only becomes true
if we receive a .decodingError
(2).
We’re now using isUsernameAvailablePublisher
to drive three different Combine pipelines, and I would like to explicitly call out that - since isUsernameAvailablePublisher
eventually will cause a network request to be fired - it is important to make sure we’re only sending at most one network request per keystroke. The previous post in this series explains how to do this in depth, but it’s worth calling out that using .share()
(3) plays a key role.
Handling Internal Server Errors
In some rare cases, the backend of our app might be having some issues - maybe part of the system is offline for maintenance, some process died, or the server is overwhelmed. Usually, servers will return a HTTP status code in the 5xx range to indicate this.
Simulating error conditions
The sample server includes code that simulates some of the error conditions discussed in this article. You can trigger the error conditions by sending specific username
values:
- Any username with less than 4 characters will result in a
tooshort
validation error, signalled via a HTTP 400 status code and a JSON payload containing a detailed error message. - An empty username will result in a
emptyName
error message, indicating the username mustn’t be empty. - Some usernames are forbidden: “admin” or “superuser” will result in an
illegalName
validation error. - Other usernames such as “peterfriese”, “johnnyappleseed”, “page”, and “johndoe” are already taken, so the server will tell the client these aren’t available any more.
- Sending “illegalresponse” as the username will return a JSON response that has too few fields, resulting in a decoding error on the client.
- Sending “servererror” will simulate a database problem (
databaseCorrupted
), and will be signalled as a HTTP 500 with no retry hint (as we assume that this is not a temporary situation, and retrying would be futile). - Sending “maintenance” as the username will return a
maintenance
error, along with aretry-after
header that indicates the client can retry this call after a period of time (the idea here is that the server is undergoing scheduled maintenance and will be back up after rebooting).
Let’s add the code required to deal with server-side errors. As we did for previous error scenarios, we need to add some code to map the HTTP status code to our APIError
enum:
To display a user-friendly error messge in our UI, all we need to do is add a few lines of code to the view model:
So far, so good.
For some of the server-side error scenarios, it might be worthwhile to retry the request after a short while. For a example, if the server underwent maintenance, it might be back up again after a few seconds.
Combine includes a retry
operator that we can use to automatically retry any failing operation. Adding it to our code is a simple one-liner:
However, as you will notice when you run the app, this will result in any failed request to be retried three times. This is not what we want - for example, we want any verification errors to bubble up to the view model. Instead, they will be captured by the retry operator as well.
What’s more, there is no pause between retries. If our goal was to reduce the pressure on a server that is already overwhelmed, we’ve made it even worse by sending not one, but four requests (the original request, plus three retries).
So how can we make sure that
- We only retry certain types of failiures?
- There is a pause before we retry a failed request?
Our implementation needs to be able to catch any upstream errors, and propagate them down the pipeline to the next operator. When we catch a serverError
, however, we want to pause for a moment, and them start the entire pipeline again so it can retry the URL request.
Let’s first make sure we can (1) catch all errors, (2) filter out the serverError
, and (3) propagate all other errors along the pipeline. The tryCatch
operator “handles errors from an upstream publisher by either replacing it with another publisher or throwing a new error”. This is exactly what we need:
When we caught a serverError
, we want to wait for a short amount of time, and then restart the pipeline.
We can do this by firing off a new event (using the Just
publisher), delay
ing it for a few seconds, and then using flatMap
to kick off a new dataTaskPublisher
. Instead of pasting the entire code for the pipeline inside the if
statement, we assign the dataTaskPublisher
to a local variable:
A couple of notes about this code:
- The
Just
publisher expects some value it can publish. Since it really doesn’t matter which value we use, we can send anything we want. I decided to send an empty tuple, which is often used in situations when you mean “nothing”. - We retry sending the request 10 times, meaning it will be sent up to 11 times in total (the original call plus the 10 retries).
The only reason why this number is so high is to make it easier to see that the pipeline comes to an end as soon as the server returns a successful result. The demo server can simulate recovering from scheduled maintenance when you send maintenance as the username: it will throw InternalServerError.maintenance
(which is mapped to HTTP 500) for every first and second request. Every third request, it will return a success
(i.e. HTTP 200
). The best way to see this in action is to run the server from inside Xcode (run open the server
project and press the Run button). Then, create a Sound breakpoint for the line that contains throw InternalServerError.maintenance:
Everytime the server receives a request for username=maintenace
, you will hear a sound. Now, run the sample app and enter maintenance as the username. You will hear the server responding with an error two times, before it will return a success.
Closure
After using a rather lenient approach to handle errors in the recent episode of this series, we took things a lot more serious this time around.
In this episode, we used a couple of strategies to handle errors and expose them to the UI. Error handling is an important aspect of developer quality software, and there is a lot of material out there. However, the aspect of how to expose erorrs to the user isn’t often discussed, and I hope this article provided you with a better understanding of how you can achieve this.
In comparison to the original code, the code became a bit more complicated, and this is something we’re going to address in the next episode when we will look at implementing your own Combine operators. To demonstrate how this works, we will implement an operator that makes handling incremental backoff as easy as adding one line to your Combine pipeline!
Thanks for reading 🔥
Improve your app's UX with SwiftUI's task view modifier
Mastering the art of the pause
SwiftUI Hero Animations with NavigationTransition
Replicating the App Store Hero Animation
Styling SwiftUI Views
How does view styling work?
Previewing Stateful SwiftUI Views
Interactive Previews for your SwiftUI views
Asynchronous programming with SwiftUI and Combine
The Future of Combine and async/await