How to Retry Network Requests Using Swift’s Combine
In this article we will explore how to leverage Combine to facilitate retrying network requests. We will do so by using a powerful .retry(_:)
operator.
In short, this is what you will know after completing the tutorial:
- How to use the
.retry(_:)
operator. - How to schedule delay time after each failed request.
- How to design a sample
UIViewController
according to the state and result of the network operation.
The source code of the finished project is available at the bottom of the article.
Let’s Start
We start with a simple project showing a UIImageView
in the center of the screen:
The ViewController
’s code is simple too:
import UIKit
import Combine
class ViewController: UIViewController {
@IBOutlet var imageView: UIImageView!
override func viewDidLoad() {
super.viewDidLoad()
overrideUserInterfaceStyle = .light
}
}
With basic structure done, let’s create a custom Error
enum that we will use to represent errors and a cancellables
property to store our subscriptions.
import UIKit
import Combine
enum CustomError: Error {
case dataCorrupted
case serverFailure
}
class ViewController: UIViewController {
...
private var cancellables = Set<AnyCancellable>()
override func viewDidLoad() {
...
}
}
Now we can start creating a network request. Let’s adjust the file as follows:
import UIKit
import Combine
...
class ViewController: UIViewController {
...
override func viewDidLoad() {
...
}
private func getAvatarFromTheServer() -> AnyPublisher<UIImage, Error> {
// 1
let url = URL(string: "https://picsum.photos/1000")!
// 2
return Deferred {
Future { promise in
DispatchQueue.global().async {
guard let data = try? Data(contentsOf: url),
let image = UIImage(data: data) else {
// 3
promise(.failure(CustomError.dataCorrupted))
return
}
// 4
promise(.success(image))
}
}
}.eraseToAnyPublisher()
}
}
- The
url
provides us with a random image. Future
produces a single event and then finishes or fails. However, we want to be able to retry the request, hence we wrap theFuture
in aDeferred
publisher.- If we couldn’t extract
Data
andUIImage
from the response, we send a.failure(CustomError.dataCorrupted)
event. - If we obtained a
UIImage
successfully, a.success(image)
event is sent.
We call the implemented method inside the viewDidLoad()
method of the ViewController
.
import UIKit
import Combine
...
class ViewController: UIViewController {
...
override func viewDidLoad() {
...
getAvatarFromTheServer()
// 1
.handleEvents(receiveSubscription: { print("Subscribed", $0)},
receiveOutput: { print("Got image", $0)},
receiveCompletion: { print("Completion", $0)})
// 2
.receive(on: DispatchQueue.main)
// 3
.sink(receiveCompletion: { completion in
switch completion {
case let .failure(error):
print("Finished with error: \(error)")
case .finished:
print("Finished")
}
}, receiveValue: { [weak self] image in
// 4
self?.imageView.image = image
})
.store(in: &cancellables)
}
...
}
Here we achieve the following:
- Monitor and print happening events inside the
.handleEvents
operator. - Signal that we want to get the result of the operation on the main thread, by using the
.receive(on:)
operator. - Print a message in the console once the whole sequence terminated. This will happen after all the retry attempts, or after a successful image loading task.
- Once the image is obtained, show it in the
UIImageView
.
So far it looks good, but what if our request fails? Let’s implement the retrying functionality by adding the .retry(_:)
operator between .handleEvents
and .receive
.
import UIKit
import Combine
...
class ViewController: UIViewController {
...
override func viewDidLoad() {
...
getAvatarFromTheServer()
.handleEvents(receiveSubscription: { print("Subscribed", $0)},
receiveOutput: { print("Got image", $0)},
receiveCompletion: { print("Completion", $0)})
.retry(3)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { completion in
switch completion {
case let .failure(error):
print("Finished with error: \(error)")
case .finished:
print("Finished")
}
}, receiveValue: { [weak self] image in
self?.imageView.image = image
})
.store(in: &cancellables)
}
private func getAvatarFromTheServer() -> AnyPublisher<UIImage, Error> {
...
}
}
Here we say that in case of an error, we can retry the request up to three times. Let’s intentionally modify the URL
inside the getAvatarFromTheServer()
method so that it fails:
import UIKit
import Combine
...
class ViewController: UIViewController {
...
private func getAvatarFromTheServer() -> AnyPublisher<UIImage, Error> {
let url = URL(string: "https://picsum.photos/corrupted")!
...
}
}
Now if we run the app, we will see four attempts to load the image (since retry count parameters adds to the initial attempt):
One more task done, let’s now see if we can optimize the operation. Currently, the retry attempt starts immediately after failure, while ideally we would want to have a small delay between consequent attempts. Here is where the .delay
comes to the rescue. Let’s add it between the .handleEvents
and .retry
operators:
import UIKit
import Combine
...
class ViewController: UIViewController {
...
override func viewDidLoad() {
super.viewDidLoad()
overrideUserInterfaceStyle = .light
getAvatarFromTheServer()
.handleEvents(...)
//
.delay(for: 1, scheduler: DispatchQueue.global())
//
.retry(3)
.receive(on: DispatchQueue.main)
.sink(...)
.store(in: &cancellables)
}
private func getAvatarFromTheServer() -> AnyPublisher<UIImage, Error> {
...
}
}
Here we specified we want to delay each retry attempt by 1 second. In case the operation failed due to slow internet connection, this delay gives a user more opportunity to recover the connection and get the eventual result, which is image.
Now we can revert the corrupted URL back to its correct state and run the app again:
import UIKit
import Combine
...
class ViewController: UIViewController {
...
private func getAvatarFromTheServer() -> AnyPublisher<UIImage, Error> {
let url = URL(string: "https://picsum.photos/1000")!
...
}
}
As we can see, the image was loaded as needed, no retry download attempts happened. Now our goal is to reflect the state of the image loading task in our UI. For simplicity, we will bind the state to the title of the UINavigationItem
of the ViewController
.
First, we add the following State
enum to the file and initialize the Published
property with .initial
case.
import UIKit
import Combine
...
enum State: String {
case initial = "Initial"
case loading = "Loading"
case loadedSuccessfully = "Success"
case loadingFailed = "Failure"
}
class ViewController: UIViewController {
...
@Published var state: State = .initial
...
}
Next, we set the state
accordingly inside the image loading task. Note that we moved the binding process into a method called bindAvatarToImageView()
:
import UIKit
import Combine
...
enum State: String {
case initial = "Initial"
case loading = "Loading"
case loadedSuccessfully = "Success"
case loadingFailed = "Failure"
}
class ViewController: UIViewController {
...
@Published var state: State = .initial
override func viewDidLoad() {
...
}
private func bindAvatarToImageView() {
getAvatarFromTheServer()
.handleEvents(receiveSubscription: { [weak self] in
print("Subscribed", $0)
// 1
self?.state = .loading
}, receiveOutput: {
...
}, receiveCompletion: {
...
})
.delay(...)
.retry(...)
.receive(...)
.sink(receiveCompletion: { [weak self] completion in
switch completion {
case let .failure(error):
print("Finished with error: \(error)")
// 2
self?.state = .loadingFailed
case .finished:
print("Finished")
}
}, receiveValue: { [weak self] image in
self?.imageView.image = image
// 3
self?.state = .loadedSuccessfully
})
.store(in: &cancellables)
}
private func getAvatarFromTheServer() -> AnyPublisher<UIImage, Error> {
...
}
}
- Inside the
.handleEvents(receiveSubscription:)
closure, we set thestate
to.loading
. - When the image loading task finished with error(if needed, with retry attempts), inside the
.sink(receiveCompletion:)
closure, we set the state to.loadingFailed
. Similarly, inside the.sink(receiveValue:)
closure, thestate
is set to.loadedSuccessfully
.
Now we can bind the state
property to the UINavigationItem
title. Let’s add a bindStateToTitle()
method:
import UIKit
import Combine
...
class ViewController: UIViewController {
...
private func bindStateToTitle() {
self.$state
// 1
.receive(on: DispatchQueue.main)
// 2
.map { $0.rawValue }
// 3
.handleEvents(receiveOutput: { [weak self] stateString in
self?.navigationItem.title = stateString
})
.sink { _ in }
.store(in: &cancellables)
}
private func bindAvatarToImageView() {
...
}
private func getAvatarFromTheServer() -> AnyPublisher<UIImage, Error> {
...
}
}
- We receive the
state
updates on the main thread. - Obtain a
String
value from the obtainedstate
, since thestate
property is anenum
. - Assign the value to the title of the
UINavigationItem
.
Great! Now that we are done with logic, let’s call it inside the viewDidLoad()
method:
import UIKit
import Combine
...
class ViewController: UIViewController {
...
override func viewDidLoad() {
super.viewDidLoad()
overrideUserInterfaceStyle = .light
// 1
bindStateToTitle()
// 2
DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
self.bindAvatarToImageView()
}
}
...
}
- We subscribe to the state updates by launching the
bindStateToTitle()
method. - Launch image loading and binding method 2 seconds after
viewDidLoad()
is run, since we want to see the “Initial” title shown in the navigation item first.
Now, considering that the task succeeds, the user will see this:
We have successfully implemented the retrying functionality for network requests and also bound the state to the UI.
What if the above logic is not enough, and you want to supply a placeholder image in case all network requests failed? Combine got you covered once again! We use the .replaceError
operator to achieve exactly that. We add it between the .retry
and .receive
operators.
private func bindAvatarToImageView() {
getAvatarFromTheServer()
.handleEvents(receiveSubscription: { [weak self] in
print("Subscribed", $0)
self?.state = .loading
}, receiveOutput: {
print("Got image", $0)
}, receiveCompletion: {
print("Completion", $0)
})
.delay(for: 1, scheduler: DispatchQueue.global())
.retry(3)
.replaceError(with: UIImage(named: "placeholder")!)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
print("Finished")
}
}, receiveValue: { [weak self] image in
self?.imageView.image = image
self?.state = .loadedSuccessfully
})
.store(in: &cancellables)
}
And here is the result:
Note that now the state
will not get .loadingFailed
case anymore, since the operation always succeeds with an image.
Resources
The source code is available on GitHub.
Wrapping Up
We have seen how easy it is to implement a not-so straightforward logic by leveraging Combine. To learn more about Combine operators, visit the official documentation. I hope you’ve found this tutorial useful, thanks for reading!