Количество просмотров938
21 января 2022

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()
    }
}
  1. The url provides us with a random image.
  2. Future produces a single event and then finishes or fails. However, we want to be able to retry the request, hence we wrap the Future in a Deferred publisher.
  3. If we couldn’t extract Data and UIImage from the response, we send a .failure(CustomError.dataCorrupted) event.
  4. 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:

  1. Monitor and print happening events inside the .handleEvents operator.
  2. Signal that we want to get the result of the operation on the main thread, by using the .receive(on:) operator.
  3. 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.
  4. 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> {
      ...
    }

}
  1. Inside the .handleEvents(receiveSubscription:) closure, we set the state to .loading.
  2. 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, the state 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> {
        ...
    }

}
  1. We receive the state updates on the main thread.
  2. Obtain a String value from the obtained state, since the state property is an enum.
  3. 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()
        }   
    }
  
    ...
}
  1. We subscribe to the state updates by launching the bindStateToTitle() method.
  2. 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!