Leverage async/await to write better generic network layer

In all programming languages, concurrency is a fundamental yet sensitive subject to deal with – and in Swift – it is no exception.

GCD has helped developers implement safe concurrent code for years. In WWDC21, Apple introduced asynchronous functions in Swift, a pattern known as async/await.

In this article you will learn how to leverage the async/await pattern to write clean and concise networking code. For that. we will take the code we wrote in the last article and upgrade it to use the modern async/await pattern.

Completion handler vs return

Consider the following code:

 func request<T: Decodable>(request: URLRequest, completion: @escaping (Result<T, HTTPError>) -> Void) {

        URLSession.shared.dataTask(with: request) { data, urlResponse, error in

            // 1 check the response
            guard let urlResponse = urlResponse as? HTTPURLResponse, (200...299).contains(urlResponse.statusCode) else {
                completion(.failure(.failedResponse))
                return
            }

            // 2 check the data
            guard let data = data else {
                completion(.failure(.invalidData))
                return
            }

            // 3 Decode the data
            do {
                let decodedData = try JSONDecoder().decode(T.self, from: data)
                completion(.success(decodedData))
            } catch {
                completion(.failure(.failedDecoding))
            }
        }.resume()
    }

The function above simply takes a request and a completion handler as parameters. It sends the request to the server using the URLSession dataTask(with:completionHandler:) API then calls back the completion handler with the decodable data in case of success, or with the error in case of failure.

The URLSession dataTask API is asynchronous, that means it will run without blocking the main queue so our program can continue to execute other tasks while waiting for the network task to complete.

The return type of the function is represented by the completion parameter and the code is not read sequentially. This readability issue may be more noticeable if we end up with nested completion handlers.

Wouldn’t it be easier to re-write this function by explicitly specifying its return type? Wouldn’t it be more readable if the function signature simply takes a request as a parameter and returns a value (decodable data or error) and if the function body can be easily read sequentially?

Fortunately, leveraging the async/await pattern, we can accomplish all of the readability wins stated earlier.

Using this pattern, we explicitly mark a function as asynchronous, this way the compiler will run all the code inside the function asynchronously. A rule a thumb to remember is the following:

If the function is marked with the async keyword, you should mark the call to that function with the await keyword.

Refactoring the function above using the async/await pattern lead to the following:

func request<T: Decodable>(request: URLRequest) async throws -> T {
   let (data, response) = try await URLSession.shared.data(for: request)

   guard let safeResponse = response as? HTTPURLResponse, (200...299).contains(safeResponse.statusCode) else {
      throw HTTPError.failedResponse
   }

   do {
      let decodedData = try JSONDecoder().decode(T.self, from: data)
      return decodedData
   } catch let error{
      print(error.localizedDescription)
      throw HTTPError.failedDecoding
   }
 }

With the new implementation in place, we can list some great wins compared to the old implementation:

1- The function signature makes it explicitly clear that it will run asynchronously.

2- Always on the function signature, the keyword throws makes it clear that the function may throw errors. Along with the return type we already know that the function will either return a decodable data object or an error.

3- The call to data(for:) API is done with the await keyword, this is because the API itself is an async method – Remember our rule of thumb? 🙂. As a result the call and the return are done on the same statement. No need to look at the completion handler closing bracket any more.

As you can see, the refactored code is now more concise and – most importantly – can be read sequentially compared to the old fashioned code.

Call the async function

Now that our function leverages the new async/await pattern, it is time to call it.

With the old fashioned completion handler – we used to call our networking function using the following syntax:

 func getPosts(completion: @escaping (Result<[Post],HTTPError>) -> Void) {

   var components = URLComponents()
   components.scheme = apiConfig.scheme
   components.host = apiConfig.host
   components.path = Routes.posts.callAsFunction()

   guard let url = components.url else {
       completion(.failure(.invalidUrl))
       return
   }

   var urlRequest = URLRequest(url: url)
   urlRequest.httpMethod = HTTPMethod.GET.callAsFunction()
   // Send the request
   apiFetcher.request(request: urlRequest) { result in
      completion(result)
   }
}

But now as we are adopting the new patterns, we can change the signature and implementation of the function above to the following:

func getPosts() async throws -> [Post] {
   var urlComponents: URLComponents = URLComponents()
   urlComponents.path = Routes.posts.callAsFunction()
   urlComponents.scheme = apiConfig.scheme
   urlComponents.host = apiConfig.host

   guard let safeURL = urlComponents.url else {
      throw HTTPError.invalidUrl
   }
   var request: URLRequest = URLRequest(url: safeURL)
   request.httpMethod = HTTPMethod.GET.callAsFunction()
   // Send the request
   return try await apiFetcher.request(request: request)
 }

We only changed two parts compared to the old implementation:

1- The signature which no longer have a completion handler and thus leverages the async throws patterns.

2- The last line which now syntactically immediately returns the result of the call to our previous network function. Please note that the call remains asynchronous but the new syntax makes it read as if it is not.

Also, since the network function is an async and marked with throw, thus calling it requires the use of the await and try keywords when invoking it.

Wrap it up

Now that all the pieces are implemented, let’s make a final call to trigger all the chain.

Task {
    do {
      let posts = try await getPosts()
      update(with: posts)
    } catch {
      print("AN ERROR OCCURED")
    }
}

With what we have learned so far, we make a call to the getPosts function. Since the getPosts function is async and marked with throws, thus calling it is done using await and try.

We wrapped all this in a Task closure to tell the compiler that this code needs to execute asynchronously. This way we can put it inside a method that doesn’t support concurrency by default (like the viewDidLoad method).

Final piece

Once the data is returned from the server, it is time to display it. Let’s implement the update(with:) function:

 @MainActor
 private func update(with posts: [Post]) {
    self.posts = posts
    self.tableView.reloadData()
 }

The @MainActor would explicitly specify that the update(with:) function should run on the main queue regardless of the caller context. This way we are sure that updating the UI is done safely.

Conclusion

As usual, the sample project code is available on GitHub.

This is a quick introduction around async/await pattern. In upcoming articles I will try to explain more concurrency concepts. In meantime, I recommend you watch the excellent WWDC 2021 video Swift concurrency: Update a sample app which walk you through how to update old fashioned code to leverage the new concurrency patterns – Like we just did in this article 🙂

Feel free to ping me on Twitter if you have any question.

Thanks!

[mailerlite_form form_id=1]

Malek
iOS developer with over than 11 years of extensive experience working on several projects with different sized startups and corporates.