Network request and decoding errors in Swift
This is a listing of raw data returned from different cases of network requests and model decoding in Swift, with emphasis on error cases. Useful for the purpose of mocking error responses.
The Star Wars API is used as reference.
Summary table
Case | Data | Response | Network error | Decoding |
---|---|---|---|---|
1) Successful | Some | 200 | nil | Ok |
2) Non-existing host | nil | nil | NSURLErrorDomain (-1003) |
N/A |
3) Non-existing resource | Some | 404 | nil | dataCorrupted |
4) Non-existing query | Some | 200(a) | nil | typeMismatch (a) |
5) Corrupted data | Some | 200 | nil | dataCorrupted |
6) Model mismatch | Some | 200 | nil | keyNotFound |
7) Offline mode | nil | nil | NSURLErrorDomain (-1009) |
N/A |
(a) Some APIs return 404 when a non-existing query is used, giving keyNotFound
as error when decoding. The sample StarWars API returns a valid response, just ignoring the non-existing query.
Sample output of playground follows.
Exercise 1: Successful request
Data: :
Optional("{"people":"https://swapi.co/api/people/","planets":"https://swapi.co/api/planets/","films":"https://swapi.co/api/films/","species":"https://swapi.co/api/species/","vehicles":"https://swapi.co/api/vehicles/","starships":"https://swapi.co/api/starships/"}")
Response:
<NSHTTPURLResponse: 0x6000000338c0> { URL: https://swapi.co/api/ } { Status Code: 200, Headers {
"Content-Encoding" = (
gzip
);
"Content-Type" = (
"application/json"
);
Date = (
"Wed, 16 May 2018 12:09:45 GMT"
);
Etag = (
"W/"c1df070b0509ebefe72e85d721f40bf0""
);
Server = (
cloudflare
);
Vary = (
"Accept, Cookie"
);
Via = (
"1.1 vegur"
);
allow = (
"GET, HEAD, OPTIONS"
);
"cf-ray" = (
"41bdbcbb7f7263df-FRA"
);
"expect-ct" = (
"max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct""
);
"x-frame-options" = (
SAMEORIGIN
);
} }
Network error: none
Decoded model:
API(films: https://swapi.co/api/films/, people: https://swapi.co/api/people/, planets: https://swapi.co/api/planets/, species: https://swapi.co/api/species/, starships: https://swapi.co/api/starships/, vehicles: https://swapi.co/api/vehicles/)
Exercise 2: non-existing host
Data: none
Response: none
Network Error :
Error Domain=NSURLErrorDomain Code=-1003 "A server with the specified hostname could not be found." UserInfo={NSUnderlyingError=0x600001055d80 {Error Domain=kCFErrorDomainCFNetwork Code=-1003 "(null)" UserInfo={_kCFStreamErrorCodeKey=8, _kCFStreamErrorDomainKey=12}}, NSErrorFailingURLStringKey=https://nonexisting.co/api/, NSErrorFailingURLKey=https://nonexisting.co/api/, _kCFStreamErrorDomainKey=12, _kCFStreamErrorCodeKey=8, NSLocalizedDescription=A server with the specified hostname could not be found.}
Exercise 3: non-existing resource
Data:
Optional("<!DOCTYPE html>n<html lang="en">nn<head>n<meta charset="utf-8">n<title>n SWAPI - The Star Wars APIn </title>n<meta name="viewport" content="width=device-width, initial-scale=1.0">n<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css">n<link href="/static/css/bootstrap.css" rel="stylesheet">n<link href="/static/css/custom.css" rel="stylesheet">n<link rel="shortcut icon" href="/static/favicon.ico">n</style>n<script src="//code.jquery.com/jquery-2.1.0.min.js" type="12b2974bbe4f5b3be4eb1eb3-"></script>n<script src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.0/js/bootstrap.min.js" type="12b2974bbe4f5b3be4eb1eb3-"></script>n</script>n</head>n<body>n<nav class="navbar navbar-default" role="navigation">n<div class="container-fluid">n<div class="navbar-header">n<button
(abridged for brevity)
Response:
<NSHTTPURLResponse: 0x60400002eec0> { URL: https://swapi.co/api/chewbacca/ } { Status Code: 404, Headers {
"Content-Encoding" = (
gzip
);
"Content-Type" = (
"text/html; charset=utf-8"
);
Date = (
"Wed, 16 May 2018 12:54:30 GMT"
);
Server = (
cloudflare
);
Via = (
"1.1 vegur"
);
"cf-ray" = (
"41bdfe484ca263df-FRA"
);
"expect-ct" = (
"max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct""
);
"x-frame-options" = (
SAMEORIGIN
);
} }
Network error: none
Decoding error:
dataCorrupted(Swift.DecodingError.Context(codingPath: [], debugDescription: "The given data was not valid JSON.", underlyingError: Optional(Error Domain=NSCocoaErrorDomain Code=3840 "JSON text did not start with array or object and option to allow fragments not set." UserInfo={NSDebugDescription=JSON text did not start with array or object and option to allow fragments not set.})))
Exercise 4: non-existing query
Data:
Optional("{"count":7,"next":null,"previous":null,"results":[{"title":"A New Hope","episode_id":4,"opening_crawl":"It is a period of civil war.\r\nRebel spaceships, striking\r\nfrom a hidden base, have won\r\ntheir first victory against\r\nthe evil Galactic Empire.\r\n\r\nDuring the battle, Rebel\r\nspies managed to steal secret\r\nplans to the Empire's\r\nultimate weapon, the DEATH\r\nSTAR, an armored space\r\nstation with enough power\r\nto destroy an entire planet.\r\n\r\nPursued by the Empire's\r\nsinister agents, Princess\r\nLeia races home aboard her\r\nstarship, custodian of the\r
(abridged for brevity)
Response:
<NSHTTPURLResponse: 0x6000002237a0> { URL: https://swapi.co/api/films/?nonexisting=query } { Status Code: 200, Headers {
"Content-Encoding" = (
gzip
);
"Content-Type" = (
"application/json"
);
Date = (
"Thu, 17 May 2018 09:37:31 GMT"
);
Etag = (
"W/"a6f57b83572279727b3a9208d0653a82""
);
Server = (
cloudflare
);
Vary = (
"Accept, Cookie"
);
Via = (
"1.1 vegur"
);
allow = (
"GET, HEAD, OPTIONS"
);
"cf-ray" = (
"41c51b159f046403-FRA"
);
"expect-ct" = (
"max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct""
);
"x-frame-options" = (
SAMEORIGIN
);
} }
Network error: none
Decoding error:
typeMismatch(Swift.Double, Swift.DecodingError.Context(codingPath: [CodingKeys(stringValue: "results", intValue: nil), _JSONKey(stringValue: "Index 0", intValue: 0), CodingKeys(stringValue: "releaseDate", intValue: nil)], debugDescription: "Expected to decode Double but found a string/data instead.", underlyingError: nil))
Exercise 5: corrupted data (non-conforming JSON)
Data: (overriden by playground for sample of corrupted data)
Response:
<NSHTTPURLResponse: 0x600000026fc0> { URL: https://swapi.co/api/ } { Status Code: 200, Headers {
"Content-Encoding" = (
gzip
);
"Content-Type" = (
"application/json"
);
Date = (
"Wed, 16 May 2018 12:54:34 GMT"
);
Etag = (
"W/"c1df070b0509ebefe72e85d721f40bf0""
);
Server = (
cloudflare
);
Vary = (
"Accept, Cookie"
);
Via = (
"1.1 vegur"
);
allow = (
"GET, HEAD, OPTIONS"
);
"cf-ray" = (
"41bdfe5c684a6415-FRA"
);
"expect-ct" = (
"max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct""
);
"x-frame-options" = (
SAMEORIGIN
);
} }
Network error: none
Decoding error:
dataCorrupted(Swift.DecodingError.Context(codingPath: [], debugDescription: "The given data was not valid JSON.", underlyingError: Optional(Error Domain=NSCocoaErrorDomain Code=3840 "JSON text did not start with array or object and option to allow fragments not set." UserInfo={NSDebugDescription=JSON text did not start with array or object and option to allow fragments not set.})))
Exercise 6: model mismatch / wrong decoding key
Data:
Optional("{"people":"https://swapi.co/api/people/","planets":"https://swapi.co/api/planets/","films":"https://swapi.co/api/films/","species":"https://swapi.co/api/species/","vehicles":"https://swapi.co/api/vehicles/","starships":"https://swapi.co/api/starships/"}")
Response:
<NSHTTPURLResponse: 0x60400002e3e0> { URL: https://swapi.co/api/ } { Status Code: 200, Headers {
"Content-Encoding" = (
gzip
);
"Content-Type" = (
"application/json"
);
Date = (
"Wed, 16 May 2018 12:54:35 GMT"
);
Etag = (
"W/"c1df070b0509ebefe72e85d721f40bf0""
);
Server = (
cloudflare
);
Vary = (
"Accept, Cookie"
);
Via = (
"1.1 vegur"
);
allow = (
"GET, HEAD, OPTIONS"
);
"cf-ray" = (
"41bdfe63fc5863af-FRA"
);
"expect-ct" = (
"max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct""
);
"x-frame-options" = (
SAMEORIGIN
);
} }
Network error: none
Decoding error:
keyNotFound(CodingKeys(stringValue: "count", intValue: nil), Swift.DecodingError.Context(codingPath: [], debugDescription: "No value associated with key CodingKeys(stringValue: "count", intValue: nil) ("count").", underlyingError: nil))
Exercise 7: request in offline mode (disable host connection in playground to get it)
Data: none
Response: none
Network Error :
Error Domain=NSURLErrorDomain Code=-1009 "The Internet connection appears to be offline." UserInfo={NSUnderlyingError=0x60400005fbf0 {Error Domain=kCFErrorDomainCFNetwork Code=-1009 "(null)" UserInfo={_kCFStreamErrorCodeKey=50, _kCFStreamErrorDomainKey=1}}, NSErrorFailingURLStringKey=https://swapi.co/api/, NSErrorFailingURLKey=https://swapi.co/api/, _kCFStreamErrorDomainKey=1, _kCFStreamErrorCodeKey=50, NSLocalizedDescription=The Internet connection appears to be offline.}
Forums > SwiftUI
|
@aliabuahmadeh Aug ’20 (i have the below code for decoding JSON data, but i am not getting th response. Instead, I get «Unknown error», which means the URL is correct, but i am not able to define where the problem is.)
1
|
|
@delawaremathguy HWS+ Aug ’20 hi, URLSession is not one of my strong suits, but i have two ideas. (1) the first debugging step is to catch any error thrown in the JSON decoding, perhaps something like this
(2) i see that you have defined hope that helps, DMG
1
|
|
@NigelGee HWS+ Aug ’20 As DMG say
1
|
|
@aliabuahmadeh Aug ’20 Hello delawaremathguy & NigelGee , your reply is highly appreciated.
1
|
|
@delawaremathguy HWS+ Aug ’20 hi, well, unfortunately, the error «The data couldn’t be read because it isn’t in the correct format» means that the data couldn’t be read because it isn’t in the correct format. (AAARRGGHH!) (1) it would help to see what the data is that you are reading and whether it matches up against your definition. (2) you could use a more informative catch handler. replace the generic catch handler
with this extended one that i have used in the past:
see if that tells you anything more. hope that helps, DMG
2
|
|
@aliabuahmadeh Aug ’20 Great… then i changed this part of code: MANY MANY THANKS
2
|
|
@roosterboy HWS+ Aug ’20 Tweaks to your original code to make it work. See the comments for explanation of changes.
5
|
|
@iam-that-one Mar ’21 How we can access friend properties?
1
|
|
@sasimpso HWS+ Sep ’21 I had the same exact problem and this was a very helpful thread for solving the problem and learning how to debug using error handling in the catch block. Thanks!
1
|
Sponsor Hacking with Swift and reach the world’s largest Swift community!
Archived topic
This topic has been closed due to inactivity, so you can’t reply. Please create a new topic if you need to.
All interactions here are governed by our code of conduct.
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
orfalse
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. Theserver
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.
func checkUserNameAvailablePublisher(userName: String) -> AnyPublisher<Bool, Never> { ... }
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
:
func checkUserNameAvailablePublisher(userName: String)
-> AnyPublisher<Bool, Never> {
guard let url = URL(string: "http://127.0.0.1:8080/isUserNameAvailable?userName=(userName)") else {
return Just(false).eraseToAnyPublisher()
}
return URLSession.shared.dataTaskPublisher(for: url)
.map(.data)
.decode(type: UserNameAvailableMessage.self, decoder: JSONDecoder())
.map(.isAvailable)
.replaceError(with: false)
.eraseToAnyPublisher()
}
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
:
enum APIError: LocalizedError {
/// Invalid request, e.g. invalid URL
case invalidRequestError(String)
}
struct AuthenticationService {
func checkUserNameAvailablePublisher(userName: String)
-> AnyPublisher<Bool, Error> {
guard let url =
URL(string: "http://127.0.0.1:8080/isUserNameAvailable?userName=(userName)") else {
return Fail(error: APIError.invalidRequestError("URL invalid"))
.eraseToAnyPublisher()
}
return URLSession.shared.dataTaskPublisher(for: url)
.map(.data)
.decode(type: UserNameAvailableMessage.self, decoder: JSONDecoder())
.map(.isAvailable)
// .replaceError(with: false)
.eraseToAnyPublisher()
}
}
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:
somePublisher
.flatMap { value in
callSomePotentiallyFailingPublisher()
.catch { error in
return Just(someDefaultValue)
}
}
.eraseToAnyPublisher()
Applying this strategy to the code in our view model results in the following code:
private lazy var isUsernameAvailablePublisher: AnyPublisher<Bool, Never> = {
$username
.debounce(for: 0.8, scheduler: DispatchQueue.main)
.removeDuplicates()
.flatMap { username -> AnyPublisher<Bool, Never> in
self.authenticationService.checkUserNameAvailablePublisher(userName: username)
.catch { error in 1
return Just(false) 2
}
.eraseToAnyPublisher()
}
.receive(on: DispatchQueue.main)
.share()
.eraseToAnyPublisher()
}()
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:
typealias Available = Result<Bool, Error>
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:
extension Publisher {
func asResult() -> AnyPublisher<Result<Output, Failure>, Never> {
self
.map(Result.success)
.catch { error in
Just(.failure(error))
}
.eraseToAnyPublisher()
}
}
This allows us to update the isUsernameAvailablePublisher
in our view model like this:
private lazy var isUsernameAvailablePublisher: AnyPublisher<Available, Never> = {
$username
.debounce(for: 0.8, scheduler: DispatchQueue.main)
.removeDuplicates()
.flatMap { username -> AnyPublisher<Available, Never> in
self.authenticationService.checkUserNameAvailablePublisher(userName: username)
.asResult()
}
.receive(on: DispatchQueue.main)
.share()
.eraseToAnyPublisher()
}()
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
enum APIError: LocalizedError {
/// Invalid request, e.g. invalid URL
case invalidRequestError(String)
/// Indicates an error on the transport layer, e.g. not being able to connect to the server
case transportError(Error)
}
struct AuthenticationService {
func checkUserNameAvailablePublisher(userName: String)
-> AnyPublisher<Bool, Error> {
guard let url = URL(string: "http://127.0.0.1:8080/isUserNameAvailable?userName=(userName)") else {
return Fail(error: APIError.invalidRequestError("URL invalid"))
.eraseToAnyPublisher()
}
return URLSession.shared.dataTaskPublisher(for: url)
.mapError { error -> Error in
return APIError.transportError(error)
}
.map(.data)
.decode(type: UserNameAvailableMessage.self, decoder: JSONDecoder())
.map(.isAvailable)
.eraseToAnyPublisher()
}
}
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:
class SignUpScreenViewModel: ObservableObject {
// ...
init() {
isUsernameAvailablePublisher
.map { result in
switch result {
case .failure(let error): 1
if case APIError.transportError(_) = error {
return "" 3
}
else {
return error.localizedDescription
}
case .success(let isAvailable):
return isAvailable ? "" : "This username is not available"
}
}
.assign(to: &$usernameMessage) 4
isUsernameAvailablePublisher
.map { result in
if case .failure(let error) = result { 2
if case APIError.transportError(_) = error {
return true
}
return false
}
if case .success(let isAvailable) = result {
return isAvailable
}
return true
}
.assign(to: &$isValid) 5
}
}
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:
struct APIErrorMessage: Decodable {
var error: Bool
var reason: String
}
// ...
struct AuthenticationService {
func checkUserNameAvailablePublisher(userName: String) -> AnyPublisher<Bool, Error> {
guard let url = URL(string: "http://127.0.0.1:8080/isUserNameAvailable?userName=(userName)") else {
return Fail(error: APIError.invalidRequestError("URL invalid"))
.eraseToAnyPublisher()
}
return URLSession.shared.dataTaskPublisher(for: url)
// handle URL errors (most likely not able to connect to the server)
.mapError { error -> Error in
return APIError.transportError(error)
}
// handle all other errors
.tryMap { (data, response) -> (data: Data, response: URLResponse) in
print("Received response from server, now checking status code")
guard let urlResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse 1
}
if (200..<300) ~= 2 urlResponse.statusCode {
}
else {
let decoder = JSONDecoder()
let apiError = try decoder.decode(APIErrorMessage.self,
from: data) 3
if urlResponse.statusCode == 400 { 4
throw APIError.validationError(apiError.reason)
}
}
return (data, response)
}
.map(.data)
.decode(type: UserNameAvailableMessage.self, decoder: JSONDecoder())
.map(.isAvailable)
// .replaceError(with: false)
.eraseToAnyPublisher()
}
}
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:
init() {
isUsernameAvailablePublisher
.map { result in
switch result {
case .failure(let error):
if case APIError.transportError(_) = error {
return ""
}
else if case APIError.validationError(let reason) = error {
return reason
}
else {
return error.localizedDescription
}
case .success(let isAvailable):
return isAvailable ? "" : "This username is not available"
}
}
.assign(to: &$usernameMessage)
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):
// ...
.map(.data)
// .decode(type: UserNameAvailableMessage.self, decoder: JSONDecoder())
.tryMap { data -> UserNameAvailableMessage in
let decoder = JSONDecoder()
do {
return try decoder.decode(UserNameAvailableMessage.self,
from: data)
}
catch {
throw APIError.decodingError(error)
}
}
.map(.isAvailable)
// ...
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):
enum APIError: LocalizedError {
/// Invalid request, e.g. invalid URL
case invalidRequestError(String)
/// Indicates an error on the transport layer, e.g. not being able to connect to the server
case transportError(Error)
/// Received an invalid response, e.g. non-HTTP result
case invalidResponse
/// Server-side validation error
case validationError(String)
/// The server sent data in an unexpected format
case decodingError(Error)
var errorDescription: String? {
switch self {
case .invalidRequestError(let message):
return "Invalid request: (message)"
case .transportError(let error):
return "Transport error: (error)"
case .invalidResponse:
return "Invalid response"
case .validationError(let reason):
return "Validation Error: (reason)"
case .decodingError:
return "The server returned data in an unexpected format. Try updating the app."
}
}
}
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:
struct SignUpScreen: View {
@StateObject private var viewModel = SignUpScreenViewModel()
var body: some View {
Form {
// ...
}
// show update dialog
.alert("Please update", isPresented: $viewModel.showUpdateDialog, actions: {
Button("Upgrade") {
// open App Store listing page for the app
}
Button("Not now", role: .cancel) { }
}, message: {
Text("It looks like you're using an older version of this app. Please update your app.")
})
}
}
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:
class SignUpScreenViewModel: ObservableObject {
// ...
@Published var showUpdateDialog: Bool = false 1
// ...
private lazy var isUsernameAvailablePublisher: AnyPublisher<Available, Never> = {
$username
.debounce(for: 0.8, scheduler: DispatchQueue.main)
.removeDuplicates()
.flatMap { username -> AnyPublisher<Available, Never> in
self.authenticationService.checkUserNameAvailablePublisher(userName: username)
.asResult()
}
.receive(on: DispatchQueue.main)
.share() 3
.eraseToAnyPublisher()
}()
init() {
// ...
// decoding error: display an error message suggesting to download a newer version
isUsernameAvailablePublisher
.map { result in
if case .failure(let error) = result {
if case APIError.decodingError = error 2 {
return true
}
}
return false
}
.assign(to: &$showUpdateDialog)
}
}
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:
if (200..<300) ~= urlResponse.statusCode {
}
else {
let decoder = JSONDecoder()
let apiError = try decoder.decode(APIErrorMessage.self, from: data)
if urlResponse.statusCode == 400 {
throw APIError.validationError(apiError.reason)
}
if (500..<600) ~= urlResponse.statusCode {
let retryAfter = urlResponse.value(forHTTPHeaderField: "Retry-After")
throw APIError.serverError(statusCode: urlResponse.statusCode,
reason: apiError.reason,
retryAfter: retryAfter)
}
}
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:
isUsernameAvailablePublisher
.map { result in
switch result {
case .failure(let error):
if case APIError.transportError(_) = error {
return ""
}
else if case APIError.validationError(let reason) = error {
return reason
}
else if case APIError.serverError(statusCode: _, reason: let reason, retryAfter: _) = error {
return reason ?? "Server error"
}
else {
return error.localizedDescription
}
case .success(let isAvailable):
return isAvailable ? "" : "This username is not available"
}
}
.assign(to: &$usernameMessage)
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:
return URLSession.shared.dataTaskPublisher(for: url)
.mapError { ... }
.tryMap { ... }
.retry(3)
.map(.data)
.tryMap { ... }
.map(.isAvailable)
.eraseToAnyPublisher()
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:
return URLSession.shared.dataTaskPublisher(for: url)
.mapError { ... }
.tryMap { ... }
.tryCatch { error -> AnyPublisher<(data: Data, response: URLResponse), Error> in 1
if case APIError.serverError(_, _, let retryAfter) = error { 2
// ...
}
throw error 3
}
.map(.data)
.tryMap { ... }
.map(.isAvailable)
.eraseToAnyPublisher()
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:
let dataTaskPublisher = URLSession.shared.dataTaskPublisher(for: url)
.mapError { ... }
.tryMap { ... }
return dataTaskPublisher
.tryCatch { error -> AnyPublisher<(data: Data, response: URLResponse), Error> in
if case APIError.serverError = error {
return Just(()) 1
.delay(for: 3, scheduler: DispatchQueue.global())
.flatMap { _ in
return dataTaskPublisher
}
.retry(10) 2
.eraseToAnyPublisher()
}
throw error
}
.map(.data)
.tryMap { ... }
.map(.isAvailable)
.eraseToAnyPublisher()
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 🔥
One of the features that I was looking forward to this year WWDC was Codable
, which is just a type alias of the Encodable
and Decodable
protocols.
I’ve just spent a whole week shifting my Swift projects from using custom JSON parsers to Decodable
(while removing a lot of code! 🎉), this post showcases what I’ve learned along the way.
Basics
If you haven’t seen it already, I suggest you to watch the related WWDC session (the
Codable
part starts past 23 minutes).
In short: you can now convert a set of data from a JSON Object or Property List to an equivalent Struct
or Class
, basically without writing a single line of code.
Here’s an example:
import Foundation
struct Swifter: Decodable {
let fullName: String
let id: Int
let twitter: URL
}
let json = """
{
"fullName": "Federico Zanetello",
"id": 123456,
"twitter": "http://twitter.com/zntfdr"
}
""".data(using: .utf8)! // our data in native (JSON) format
let myStruct = try JSONDecoder().decode(Swifter.self, from: json) // Decoding our data
print(myStruct) // decoded!!!!!
What the Compiler Does Without Telling Us
If we look at the Decodable
documentation, we see that the protocol requires the implementation of a init(from: Decoder)
method.
We didn’t implemented it in the Playground: why did it work?
It’s for the same reason why we don’t have to implement a Swifter
initializer, but we can still go ahead and initialize our struct: the Swift compiler provides one for us! 🙌
Conforming to Decodable
All of the above is great and just works™ as long as all we need to parse is a subsets of primitives (strings, numbers, bools, etc) or other structures that conform to the Decodable
protocol.
But what about parsing more “complex structures”? Well, in this case we have to do some work.
Implementing init(from: Decoder)
⚠️ This part might be a bit trickier to understand: everything will be clear with the examples below!
Before diving into our own implementation of this initializer, let’s take a look at the main players:
The Decoder
As the name implies, the Decoder
transforms something into something else: in our case this means moving from a native format (e.g. JSON) into an in-memory representation.
We will focus on two of the Decoder’s functions:
container<Key>(keyedBy: Key.Type)
singleValueContainer()
In both cases, the Decoder
returns a (Data) Container.
With the first function, the Decoder
returns a keyed container, KeyedDecodingContainer:
to reach the actual data, we must first tell the container which keys to look for (more on this later!).
The second function tells the decoder that there’s no key: the returned container, SingleValueDecodingContainer
, is actually the data that we want!
The Containers
Thanks to our Decoder
we’ve moved from a raw native format to a structure that we can play with (our containers). Time to extract our data! Let’s take a look at the two containers that we’ve just discovered:
KeyedDecodingContainer
In this case we know that our container is keyed, you can think of this container as a dictionary [Key: Any]
.
Different keys can hold different types of data: which is why the container offers several decode(Type:forKey:)
methods.
This method is where the magic happens: by calling it, the container returns us our data’s value of the given type for the given key (examples below!).
Most importantly, the container offers the generic method decode<T>(T.Type, forKey: K) throws -> T where T: Decodable
which means that any type, as long as it conforms to Decodable
, can be used with this function! 🎉🎉
SingleValueDecodingContainer
Everything works as above, just without any keys.
Implementing our init(from: Decoder)
We’ve seen all the players that will help us go from data stored in our disk to data that we can use in our App: let’s put them all together!
Take the playground at the start of the article for example: instead of letting the compiler doing it for us, let’s implement our own init(from: Decoder)
.
Step 1: Choosing The Right Decoder
The example’s data is a JSON object, therefore we will use the Swift Library’s JSONDecoder
.
let decoder = JSONDecoder()
⚠️ JSON and P-list encoders and decoders are embedded in the Swift Library: you can write your own coders to support different formats!
Step 2: Determining The Right Container
In our case the data is keyed:
{
"fullName": "Federico Zanetello",
"id": 123456,
"twitter": "http://twitter.com/zntfdr"
}
To reach "Federico Zanetello"
we must ask for the value of key "fullName"
, to reach 123456
we must ask for the valued of index "id"
, etc.
Therefore, we must use a KeyedDecodingContainer
Container (by calling the Decoder’s method container<Key>(keyedBy: Key.Type)
).
But before doing so, as requested by the method, we must declare our keys: Key
is actually a protocol and the easiest way to implement it is by declaring our keys as an enum
of type String
:
enum MyStructKeys: String, CodingKey {
case fullName = "fullName"
case id = "id"
case twitter = "twitter"
}
Note: you don’t have to write = “…” in each case: but for clarity’s sake I’ve chosen to write it.
Now that we have our keys set up, we can go on and create our container:
let container = try decoder.container(keyedBy: MyStructKeys.self)
Step 3: Extracting Our Data
Finally, we must convert the container’s data into something that we can use in our app:
let fullName: String = try container.decode(String.self, forKey: .fullName)
let id: Int = try container.decode(Int.self, forKey: .id)
let twitter: URL = try container.decode(URL.self, forKey: .twitter)
Step 4: Initializing our Struct/Class
We can use the default Swifter initializer:
let myStruct = Swifter(fullName: fullName, id: id, twitter: twitter)
Voila! We’ve just implemented Decodable all by ourselves! 👏🏻👏🏻 Here’s the final playground:
//: Playground - noun: a place where people can play
import Foundation
struct Swifter {
let fullName: String
let id: Int
let twitter: URL
init(fullName: String, id: Int, twitter: URL) { // default struct initializer
self.fullName = fullName
self.id = id
self.twitter = twitter
}
}
extension Swifter: Decodable {
enum MyStructKeys: String, CodingKey { // declaring our keys
case fullName = "fullName"
case id = "id"
case twitter = "twitter"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: MyStructKeys.self) // defining our (keyed) container
let fullName: String = try container.decode(String.self, forKey: .fullName) // extracting the data
let id: Int = try container.decode(Int.self, forKey: .id) // extracting the data
let twitter: URL = try container.decode(URL.self, forKey: .twitter) // extracting the data
self.init(fullName: fullName, id: id, twitter: twitter) // initializing our struct
}
}
let json = """
{
"fullName": "Federico Zanetello",
"id": 123456,
"twitter": "http://twitter.com/zntfdr"
}
""".data(using: .utf8)! // our native (JSON) data
let myStruct = try JSONDecoder().decode(Swifter.self, from: json) // decoding our data
print(myStruct) // decoded!
Going further (More Playgrounds!)
Now that our Swifter
struct conforms to Decodable
, any other struct/class/etc that contains such data can automatically decode Swifter for free. For example:
Arrays
import Foundation
struct Swifter: Decodable {
let fullName: String
let id: Int
let twitter: URL
}
let json = """
[{
"fullName": "Federico Zanetello",
"id": 123456,
"twitter": "http://twitter.com/zntfdr"
},{
"fullName": "Federico Zanetello",
"id": 123456,
"twitter": "http://twitter.com/zntfdr"
},{
"fullName": "Federico Zanetello",
"id": 123456,
"twitter": "http://twitter.com/zntfdr"
}]
""".data(using: .utf8)! // our data in native format
let myStructArray = try JSONDecoder().decode([Swifter].self, from: json)
myStructArray.forEach { print($0) } // decoded!!!!!
Dictionaries
import Foundation
struct Swifter: Codable {
let fullName: String
let id: Int
let twitter: URL
}
let json = """
{
"one": {
"fullName": "Federico Zanetello",
"id": 123456,
"twitter": "http://twitter.com/zntfdr"
},
"two": {
"fullName": "Federico Zanetello",
"id": 123456,
"twitter": "http://twitter.com/zntfdr"
},
"three": {
"fullName": "Federico Zanetello",
"id": 123456,
"twitter": "http://twitter.com/zntfdr"
}
}
""".data(using: .utf8)! // our data in native format
let myStructDictionary = try JSONDecoder().decode([String: Swifter].self, from: json)
myStructDictionary.forEach { print("($0.key): ($0.value)") } // decoded!!!!!
Enums
import Foundation
struct Swifter: Decodable {
let fullName: String
let id: Int
let twitter: URL
}
enum SwifterOrBool: Decodable {
case swifter(Swifter)
case bool(Bool)
}
extension SwifterOrBool: Decodable {
enum CodingKeys: String, CodingKey {
case swifter, bool
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let swifter = try container.decodeIfPresent(Swifter.self, forKey: .swifter) {
self = .swifter(swifter)
} else {
self = .bool(try container.decode(Bool.self, forKey: .bool))
}
}
}
let json = """
[{
"swifter": {
"fullName": "Federico Zanetello",
"id": 123456,
"twitter": "http://twitter.com/zntfdr"
}
},
{ "bool": true },
{ "bool": false },
{
"swifter": {
"fullName": "Federico Zanetello",
"id": 123456,
"twitter": "http://twitter.com/zntfdr"
}
}]
""".data(using: .utf8)! // our native (JSON) data
let myEnumArray = try JSONDecoder().decode([SwifterOrBool].self, from: json) // decoding our data
myEnumArray.forEach { print($0) } // decoded!
More Complex Structs
import Foundation
struct Swifter: Decodable {
let fullName: String
let id: Int
let twitter: URL
}
struct MoreComplexStruct: Decodable {
let swifter: Swifter
let lovesSwift: Bool
}
let json = """
{
"swifter": {
"fullName": "Federico Zanetello",
"id": 123456,
"twitter": "http://twitter.com/zntfdr"
},
"lovesSwift": true
}
""".data(using: .utf8)! // our data in native format
let myMoreComplexStruct = try JSONDecoder().decode(MoreComplexStruct.self, from: json)
print(myMoreComplexStruct.swifter) // decoded!!!!!
And so on!
Before Departing
In all probability, during your first Decodable
implementations, something will go wrong: maybe it’s a key mismatch, maybe it’s a type mismatch, etc.
To detect all of these errors early, I suggest you to use Swift Playgrounds with error handling as much as possible:
do {
let myStruct = try JSONDecoder().decode(Swifter.self, from: json) // do your decoding here
} catch {
print(error) // any decoding error will be printed here!
}
You can go even deeper by splitting different types of Decoding Errors.
Even on the first Xcode 9 beta, the error messages are clear and on point 💯.
Conclusions
I was really looking forward to this new Swift 4 feature and I’m very happy with its implementation. Time to drop all those custom JSON parsers!
That’s all for today! Happy Decoding!