14.
Error Handling in Practice
Written by Florent Pillet
Life would be great if we lived in a perfect world, but unfortunately things frequently don’t go as expected. Even the best RxSwift developers can’t avoid encountering errors, so they need to know how to deal with them gracefully and efficiently. In this chapter, you’ll learn how to deal with errors, how to manage error recovery through retries, or just surrender yourself to the universe and let the errors go.
Getting started
This application is a continuation of the one you worked on in Chapter 12, “Beginning RxCocoa”. In this version of the application, you can retrieve the user’s current position and look up weather for that position, but also request a city name and see the weather in that locaiton. The app also has an activity indicator to give the user some visual feedback.
Before continuing, make sure you have a valid OpenWeatherMap API Key. If you don’t already have a key, you can sign up for one at:
Once you’ve completed the sign-up process, visit the dedicated page for API keys and generate a new one at:
Once that’s done, use Terminal to navigate to the root of the project and perform the necessary pod install.
Once the pods have been installed, open ApiController.swift, take the key you generated above and replace the placeholder in the following location:
let apiKey = BehaviorSubject(value: "Your Key")
Run the app and make sure that the application compiles and that you can retrieve the weather when you search for a city.
If that all looks good, then you can proceed right into the next section!
Managing errors
Errors are an inevitable part of any application. Unfortunately, no one can guarantee an application will never error out, so you will always need some type of error-handling mechanism.
Some of the most common errors in applications are:
-
No internet connection: This is quite common. If the application needs an internet connection to retrieve and process its data, but the device is offline, you need to be able to detect this and respond appropriately.
-
Invalid input: Sometimes you’ll require a certain form of input, but the user might enter something entirely different. Perhaps you have a phone number field in your app, but the user ignores that requirement and types letters instead of digits.
-
API error or HTTP error: Errors from an API can vary widely. They can arrive as a standard HTTP error (response codes between 400 and 500), or as errors in the response, such as using the
statusfield in a JSON response.
In RxSwift, error handling is part of the framework. For all operators that accept closures, RxSwift transforms any error thrown from within the closure into an error event that terminates the observable. This error event is something you can catch and act upon. It can be handled in two ways:
- Catch: Recover from the error with a default value.
- Retry: Retry for a limited (or unlimited!) number of times.
The starter version of this chapter’s project doesn’t have any real error handling. All the errors are caught with a single catchErrorJustReturn that returns a dummy version. This might sound like a handy solution, but there are better ways to handle this in RxSwift. A consistent and informative error-handling approach is expected in any top-notch application.
Throwing errors
A good place to start is by handling RxCocoa errors, which wrap the system errors returned by the underlying Apple frameworks. RxCocoa errors provide more details on the kind of error you’ve encountered, and also make your error handling code easier to write.
To see how the RxCocoa wrapper works under the hood, drill down in the Project Navigator in Pods project and then into Pods/RxCocoa/URLSession+Rx.swift. Search for the following method:
public func data(request: URLRequest) -> Observable<Data> {...}
This method returns an observable of type Data, created by a given URLRequest. The important part to look at is the bit of code that returns the error:
if 200 ..< 300 ~= pair.0.statusCode {
return pair.1
} else {
throw RxCocoaURLError.httpRequestFailed(response: pair.0, data: pair.1)
}
These six lines are a perfect example of how an observable can emit an error — specifically, a custom-tailored error, which you’ll cover later in this chapter.
Note there’s no return for the error in this closure. When you want to error out inside a flatMap operator, you should use throw as in regular Swift code. This is a great example of how RxSwift lets you write idiomatic Swift code where necessary, and RxSwift-style error handling where appropriate.
Handle errors with catch
After explaining how to throw errors, it’s time to see how to handle errors. The most basic way is to use catch. The catch operator works much like the do-try-catch flow in plain Swift. An observable is performed, and if something goes wrong, you return an event that wraps an error.
In RxSwift there are two main operators to catch errors. The first:
func catchError(_ handler:) -> RxSwift.Observable<Self.E>
This is a general operator; it takes a closure as parameter and gives the opportunity to return a completely different observable. If you can’t quite see where you’d use this option, think about a caching strategy that returns a previously cached value if the observable errors out. With this operator you can then achieve the following flow:
The catchError in this case returns values which were previously available and that, for some reason, aren’t available anymore.
The second operator is:
func catchErrorJustReturn(_ element:) -> RxSwift.Observable<Self.E>
You might remember seeing this one used in the two earlier chapters covering RxCocoa — it ignores errors and just returns a pre-defined value. This operator is much more limited than the previous one as it’s not possible to return a value for a given type of error — the same value is returned for any error, no matter what the error is.
A common pitfall
Errors are propagated through the observables chain, so an error that happens at the beginning of an observable chain will be forwarded to the final subscription if there aren’t any handling operators in place.
What does this mean exactly? When an observable errors out, error subscriptions are notified and all subscriptions are then disposed.
So when an observable errors out, the observable is essentially terminated and any event following the error will be ignored. This is a rule of the observable contract.
You can see this plotted below on a timeline. Once the network produces an error and the observable sequences errors out, the subscription updating the UI will stop working, effectively preventing future updates:
To see this distinction in the actual application, go to ViewController.swift and remove the .catchErrorJustReturn(.empty) line inside the textSearch observable, fire up the application and type random characters in the city search field until the API replies with a 404 error code. In this case the 404 means that the city you are looking for was not found.
You should see something similar to this in the console:
"http://api.openweathermap.org/data/2.5/weather?q=goierjgioerjgioej&appid=[API-KEY]&units=metric" -i -v
Failure (207ms): Status 404
You will also notice that the search stops working after that 404 response! Not exactly the best user experience, is it?
Catching errors
Now that you’ve covered some theory, you can move on to writing code and updating the current project. Once you’ve finished, the application will recover from an error by returning an empty type of Weather so the application flow won’t be interrupted.
The workflow this time, with included error handling, will look like this:
This is good enough, but it would be nice if the app could return cached data if available. To start, open ViewController.swift in the main project and create a simple dictionary to cache weather data, adding it as property of the view controller:
private var cache = [String: Weather]()
This will temporarily store the cached data. Scroll down within the viewDidLoad() method and search for the line where you create the textSearch observable. Now populate the cache by changing the textSearch observable by adding do(onNext:) to the code chain:
let textSearch = searchInput.flatMap { text in
return ApiController.shared.currentWeather(city: text)
.do(onNext: { [weak self] data in
self?.cache[text] = data
})
.catchErrorJustReturn(.empty)
}
With this change, every valid weather response will be stored in the dictionary. Now — how do you reuse the cached results?
To return a cached value in the event of an error, replace .catchErrorJustReturn(.empty) with:
.catchError { error in
return Observable.just(self?.cache[text] ?? .empty)
}
To test this, input three or four various cities such as “London”, “New York”, “Amsterdam” and load the weather for these cities. After that, disable your internet connection and perform a search for a different city, such as “Barcelona”; you should receive an error. Leave your internet connection disabled and search for one of the cities you just retrieved data for, and the application should return the cached version.
This is a very common usage of catch. You can definitely extend this to make it a general and powerful caching solution.
Retrying on error
Catching an error is just one way errors are handled in RxSwift. You can also handle errors with retry. When a retry operator is used and an observable errors out, the observable will repeat itself. It’s important to remember that retry means repeating the entire task inside the observable.
This is one of the main reasons it’s recommended to avoid side effects that change the user interface inside an observable, as you can’t control who will retry it!
Retry operators
There are three types of retry operators. The first one is the most basic:
func retry() -> Observable<Element>
This operator will repeat the observable an unlimited number of times until it returns successfully. For example, if there’s no internet connection, this would continuously retry until the connection was available. This might sound like a robust idea, but it’s resource-heavy, and it’s seldom recommended to retry for an unlimited number of times if there’s no valid reason for doing it.
To test this operator, comment the complete catchError block:
//.catchError { error in
// return Observable.just(self?.cache[text] ?? .empty)
//}
In its place, insert a simple retry().
Next, run the app, disable the internet connection and try to perform a search. You’ll see a lot of output in the console, showing the app is trying to make the requests.
After a few seconds, re-enable the internet connection and you’ll see the result displayed once the application has successfully processed the request.
The second operator lets you vary the number of retries:
func retry(_ maxAttemptCount:) -> Observable<Element>
With this variation, the observable is repeated for a specified number of times. To give it a try, do the following:
- Remove the
retry()operator you just added. - Uncomment the previously commented code block.
- Just before
catchError, insert aretry(3).
The complete code block should now look like this:
let textSearch = searchInput.flatMap { text in
return ApiController.shared.currentWeather(city: text)
.do(onNext: { [weak self] data in
self?.cache[text] = data
})
.retry(3)
.catchError { [weak self] error in
return Observable.just(self?.cache[text] ?? .empty)
}
}
If the observable produces errors, it will be retried up to three times in succession, meaning the initial attempt, and two additional attempts. If it errors a fourth time, that error will not be handled and execution will move on to the catchError operator.
Advanced retries
The last operator, retryWhen, is suited for advanced retry situations. This error handling operator is considered one of the most powerful:
func retryWhen(_ notificationHandler:) -> Observable<Element>
The important thing to understand is that notificationHandler is of type TriggerObservable. The trigger observable can be either a plain Observable or a Subject and is used to trigger the retry at arbitrary times.
This is the operator you will include in the current application, using a smart trick to retry if the internet connection is not available, or if there’s an error from the API. The goal is to implement an incremental back-off strategy if the original search errors out.
The desired result is as follows:
subscription -> error
delay and retry after 1 second
subscription -> error
delay and retry after 3 seconds
subscription -> error
delay and retry after 5 seconds
subscription -> error
delay and retry after 10 seconds
It’s a smart yet complex solution. In regular imperative code, this would imply the creation of some abstractions, perhaps wrapping the task in an Operation, or creating a tailored wrapper around Grand Central Dispatch — but with RxSwift, the solution is a short block of code.
Before creating the final result, consider what the inner observable (the trigger) should return, taking in consideration that the type can be ignored, and that the trigger can be of any type.
The goal is to retry four times with a given sequence of delays. First, inside ViewController.swift, just before the searchInput sequence, define the maximum number of attempts before the retryWhen operator:
let maxAttempts = 4
After this many retries, the error should be forwarded on. Then replace .retry(3) with:
.retryWhen { e in
// flatMap source errors
}
This observable has to be combined with the one that returns errors from the original observable. So when an error arrives as event, the combination of these observables will also receive the current index of the event.
You can achieve this by calling enumerated() on the observable and then using flatMap. The enumerated() method returns a new observable that sends tuples of the original observable’s values and their index. Replace the comment // flatMap source errors with:
return e.enumerated().flatMap { attempt, error -> Observable<Int> in
// attempt few times
}
Now the original error observable, and the one defining how long the delay should be before retrying, are combined.
Now combine that code with a timer, taking only the first delayed event. Adjust the code from above to look like this:
.retryWhen { e in
return e.enumerated().flatMap { attempt, error -> Observable<Int> in
if attempt >= maxAttempts - 1 {
return Observable.error(error)
}
return Observable<Int>.timer(.seconds(attempt + 1),
scheduler: MainScheduler.instance)
.take(1)
}
}
To log when the new retry is fired, add the following code before the second return in the flatMap operator:
print("== retrying after \(attempt + 1) seconds ==")
Now build and run, disable your internet connection and perform a search. You should see the following result in the log:
== retrying after 1 seconds ==
... network ...
== retrying after 2 seconds ==
... network ...
== retrying after 3 seconds ==
... network ...
Here’s a good visualization of what’s going on:
The trigger can take the original error observable into consideration to achieve quite complex back-off strategies. This shows how you can create complex error-handling strategies using only a few lines of RxSwift code.
Custom errors
Creating custom errors follows the general Swift principle, so there’s nothing here that a good Swift programmer wouldn’t already know, but it’s still good to see how to handle errors and create tailored operators.
Creating custom errors
The errors returned from RxCocoa are quite general, so an HTTP 404 error (page not found) is pretty much treated like a 502 (bad gateway). These are two completely different errors, so it would be good to be able to handle them differently.
If you dig into ApiController.swift, you’ll see there are two error cases already included that you can use to error handle different HTTP responses:
enum ApiError: Error {
case cityNotFound
case serverFailure
}
You’ll use this error type inside buildRequest(...). The last line of that method returns an observable of data. This is where you have to inject the check and return the custom error you created. The .data convenience of RxCocoa already takes care of creating the custom error object.
Replace the code found inside the block of the last flatMap in buildRequest(...):
return session.rx.response(request: request)
.map { response, data in
switch response.statusCode {
case 200 ..< 300:
return data
case 400 ..< 500:
throw ApiError.cityNotFound
default:
throw ApiError.serverFailure
}
}
Using this method, you can create custom errors and even add more advanced logic, such as when the API provides a response message inside the JSON. You could get the JSON data, process the message field and encapsulate it into the error to throw. Errors are extremely powerful in Swift, and can be made even more powerful in RxSwift.
Using custom errors
Now that you’re returning your custom error, you can do something constructive with it.
Before proceeding, back in ViewController.swift comment out the retryWhen { ... } operator. You want the error to go through the chain and be threaded by the observable.
There’s a convenience view named InfoView that flashes a small view on the bottom of the application with the given error message. The usage is pretty simple, and is done with a single line of code like this one (you don’t need to enter this right now):
InfoView.showIn(viewController: self, message: "An error occurred")
Errors are usually handled with retry or catch operators, but what if you want to perform a side effect and display the message on the user interface? To achieve this, there’s the do operator. In the same subscription where you commented retryWhen, you’ve used a do to implement caching:
.do(onNext: { [weak self] data in
self?.cache[text] = data
})
Add a second parameter to that same method call so that you perform side effects in case of an error event. The complete block should look like so:
.do(
onNext: { [weak self] data in
self?.cache[text] = data
},
onError: { error in
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
InfoView.showIn(viewController: self, message: "An error occurred")
}
}
)
The dispatch is necessary because the sequence is observed in a background thread; otherwise, UIKit will complain about the UI being modified by a background thread. Build and run, try to search on a random string and the error will show up.
Well, the error is rather general. But you can easily inject some more information in there. RxSwift handles this just as Swift would, so you can check for the error case and display different messages. To make the code a bit tidier, add this new method to the view controller class:
private func showError(error e: Error) {
guard let e = e as? ApiController.ApiError else {
InfoView.showIn(viewController: self, message: "An error occurred")
return
}
switch e {
case .cityNotFound:
InfoView.showIn(viewController: self, message: "City Name is invalid")
case .serverFailure:
InfoView.showIn(viewController: self, message: "Server error")
}
}
Then go back to the do(onNext:onError:) and replace the line InfoView.showIn(...) with:
self.showError(error: error)
This should provide more context about the error to the user. Run the app again and try typing in a random city name to see the custom error.
Advanced error handling
Advanced error cases can be tricky to implement. There’s no general rule about what to do when the API returns an error, besides show a message to the user.
Let’s assume you want to add authentication to the current application. The user has to be authenticated and authorized to request a weather condition. This would imply the creation of a session, which will make sure the user is logged in and authorized correctly. But what do you do if the session has expired? Return an error, or return an empty value alongside a message string?
There’s no silver bullet in this case. Both solutions apply here, but it’s always useful to know more about the error, so you’ll go that route.
In this case, the recommended method is to perform a side effect and retry right after the session has been correctly created.
You can use the relay called apiKey that contains your API key to simulate this behavior.
This API key relay can be used to trigger a retry in the retryWhen closure. A missing API key is definitely an error, so add the following extra error case in the ApiError enum:
case invalidKey
This error must be thrown when the server returns a 401 code. Throw that error in the builderRequest(...) function by adding a case to your switch statement. Add the following case right before case 400 ..< 500:
case 401:
throw ApiError.invalidKey
That new error also requires a new handler. Update the switch inside showError(error:) back in ViewController.swift, to include that new case:
case .invalidKey:
InfoView.showIn(viewController: self, message: "Key is invalid")
Now you can go back to viewDidLoad() and re-implement the error handling code. Since you’ve commented out the current retryWhen {...} code, you can start building your error handling anew.
Above the subscription to searchInput, create a dedicated closure, outside of the observer chain, that will serve as an error handler:
let retryHandler: (Observable<Error>) -> Observable<Int> = { e in
return e.enumerated().flatMap { attempt, error -> Observable<Int> in
// error handling
}
}
You’ll copy some of the code you had before in that new error handling closure. Replace the // error handling comment with:
if attempt >= maxAttempts - 1 {
return Observable.error(error)
} else if let casted = error as? ApiController.ApiError, casted == .invalidKey {
return ApiController.shared.apiKey
.filter { !$0.isEmpty }
.map { _ in 1 }
}
print("== retrying after \(attempt + 1) seconds ==")
return Observable<Int>.timer(.seconds(attempt + 1),
scheduler: MainScheduler.instance)
.take(1)
The return type in the invalidKey case isn’t important, but you have to be consistent. Before, it was an Observable<Int>, so you should stick with that return type. For this reason, you’ve used { _ in 1 }.
Now scroll to the commented retryWhen {...} and replace it with:
.retryWhen(retryHandler)
The final step is to use the relay of the API key. There’s already a method in ViewController.swift named requestKey(), which opens an alert view with a text field. The user then could type in the key (or paste it inside) to emulate a login functionality. You do that for testing purposes here; in a real-life app, the user would enter their credentials to get a key from your server.
Switch to ApiController.swift. Remove the API key in the apiKey subject and set it to an empty string. You might want to keep the key somewhere handy, as you will need it again in a second.
let apiKey = BehaviorSubject(value: "")
Build and run the application, try to perform a search and you’ll receive an error:
Tap the key button in the bottom-right corner:
The application will then open the alert asking for the API key:
Paste the API key in the field and tap OK. The application will repeat the whole observable sequence, returning the correct information if the input is valid. If the input isn’t valid, you’ll end up on a different error path.
Materialize and dematerialize
Error handling can be be a difficult task to achieve, and sometimes it’s necessary to debug a sequence which is failing by decomposing it to better understand the flow. Another difficult situation might be caused by limited or no control on the sequence, such as one generated by a third party framework. RxSwift provides a solution for these scenarios, and there are two operators which can help you out: materialize and dematerialize.
You’ve been introduced to the Event enum earlier in this book and are already aware of how important it is. It’s one of the foundational elements of RxSwift, but it’s rare that you’ll use it directly. The materialize operator lets you transform any sequence of T elements into a sequence of Event<T> elements.
This process transforms the original sequence into a sequence of notifications:
Using this operator, you are able to transform implicit sequences, which are manipulated with proper operators and multiple handlers, into an explicit one, so the handler for onNext, onError and onCompleted can be a single function.
To reverse a sequence of notifications, you can use dematerialize:
This will transform a sequence of notifications into a regular Observable with all the original contracts in place.
You can use these two operators in combination to create advanced and custom event loggers:
observableToLog.materialize()
.do(onNext: { (event) in
myAdvancedLogEvent(event)
})
.dematerialize()
With this approach, you can then create a custom operator using materialize and dematerialize and perform advanced tasks on the Event enumerator.
Note:
materializeanddematerializeare usually used together, and have the power to completely break the original Observable contract. Use them carefully, and only when necessary, when there are no other options to handle a particular situation.
Challenge
Challenge: Use retryWhen on restored connectivity
In this challenge you need to handle the condition of an unavailable internet connection.
To start, take a look at the reachability service inside RxReachability.swift. Modify the code so it correctly delivers the notifications when the internet connection returns.
Note: While “am I connected to the internet?” may be a simple question to ask, it’s actually quite complicated, technically, to give an accurate answer. And it’s even more complicated to simulate. If you run into problems, try running on a device instead of the iOS Simulator.
You can start monitoring the device connectivity by adding in the view controller’s viewDidLoad() method:
_ = RxReachability.shared.startMonitor("openweathermap.org")
Once that’s done, extend the retryWhen handler to handle the “no internet connection available” error. Remember that when the internet connection is up, you have to fire a retry.
To achieve this, add another if in your .enumerated().flatMap() operator where you check what kind of error has been returned.
Try casting error as NSError, and if its code equals -1009, that means the network connection is out. In that case, return RxReachability.shared.status and filter it to let through only .online values, and just as you did in the other if statement, map to 1.
The final goal is to have the system automatically retry once the internet is back, if the previous error was due to the device being offline.
As always, you can peek into the challenges folder and see the solution provided.
Where to go from here?
In this chapter, you were introduced to error handling using retry and catch. The way you handle errors in your app really depends on what kind of project you’re building. When handling errors, design and architecture come in play, and creating the wrong handling strategy might compromise your project and result in re-writing portions of your code.
I’d also recommend spending some time playing with retryWhen. It’s a non-trivial operator, so the more you play with it, the more you’ll feel comfortable using it in your applications.