Improving the issue of duplicate calls to the retry function in RequestRetrier Due to 401 Errors in Alamofire

When authentication APIs with access tokens are requested simultaneously, rather than in a chaining manner, a race condition occurs, resulting in inconsistent outcomes.

Consider the scenario where both APIs need to include an access token in the HTTP request headers:

  1. API to fetch the total number of posts to be displayed in the UICollectionView
  2. API to fetch the information to be displayed in each cell of the UICollectionView

When both APIs are called in parallel simultaneously at the viewDidAppear stage of the UIViewController, the order in which the network requests are made is unpredictable.

In this case, if the access token included in the headers of both APIs has expired, both APIs will simultaneously send requests to the server with the expired access token since they are called in parallel.

During this process, both APIs will receive a 401 error from the server. If you have implemented the RequestRetrier protocol to refresh the access token, both APIs will receive the newly issued access token from the server.

Depending on the server implementation, if the server is set to expire the existing refresh token upon issuing a new access token, a concurrency issue may arise. In this case, the refresh token that is finally issued and stored last might already be considered expired by the server.

To address this issue, I implemented a logical chaining structure for asynchronous communication by utilizing a static property in a class adopting the RequestInterceptor protocol and leveraging RxSwift’s Observable.

import UIKit

import Alamofire
import RxSwift

class APIInterceptor: RequestInterceptor {
    var disposeBag = DisposeBag()
    var retryDisposeBag = DisposeBag()

    static var isRefreshing = false
    static let retryObservable = PublishSubject<Void>()

    func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
        // adapt function..
    }

    func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {

        guard let response = request.task?.response as? HTTPURLResponse, response.statusCode == 401 else {
            completion(.doNotRetryWithError(error))
            return
        }

        if let url = request.response?.url {
            if url.relativePath == "/refresh/API/URL/Path" {
                APIInterceptor.isRefreshing = false
                completion(.doNotRetryWithError(APIError.http(status: 401)))
            } else {
                if APIInterceptor.isRefreshing {
                    APIInterceptor.retryObservable
                        .subscribe(onNext: { [unowned self] in
                            completion(.retry)
                            self.retryDisposeBag = DisposeBag()
                        })
                        .disposed(by: retryDisposeBag)
                    APIInterceptor.isRefreshing = false
                } else {
                    APIInterceptor.isRefreshing = true

                    APIRefreshTask().requestRefreshToken()
                        .subscribe(onNext: {[unowned self] result in
                            switch result {
                            case .success(let response):
                                //  save refreshed token
                                APIInterceptor.isRefreshing = false
                                APIInterceptor.retryObservable.onNext(())
                                completion(.retry)
                            case .failure:
                                //  refresh token expired
                                APIInterceptor.isRefreshing = false
                                APIInterceptor.retryObservable.onNext(())
                                completion(.doNotRetry)
                            }
                        })
                        .disposed(by: disposeBag)
                }
            }
        }
    }

    deinit {
        retryDisposeBag = DisposeBag()
        disposeBag = DisposeBag()
    }
}

Main Roles and Flow

  1. APIInterceptor Class
    • Implements the RequestInterceptor protocol to intercept and modify Alamofire requests, as well as handle retry logic.
    • Uses two DisposeBag instances to manage RxSwift subscriptions.

  2. Static Properties and PublishSubject
    • static var isRefreshing = false: A flag indicating whether a token refresh is in progress.
    • static let retryObservable = PublishSubject(): A publish subject to signal when the token refresh is complete.

  3. adapt Method
    • Used to modify requests before they are sent. (This typically includes adding the access token to the request headers, though it is not shown in this example.)

  4. retry Method
    • Defines the retry logic when a request fails.
    • Only handles 401 errors (authentication failure).
    • Checks if a token refresh request is already in progress (isRefreshing).
    • If a refresh is in progress, subscribes to retryObservable and retries the request when the refresh completes.
    • If no refresh is in progress, initiates a token refresh request.

  5. Token Refresh Logic

  • Calls TokenRefreshAPIObservable() to request a new token. This is a custom token refresh API request service object.
  • On successful token refresh:
    • Saves the new token and signals completion through retryObservable, prompting waiting requests to retry.
  • On token refresh failure:
    • Returns a 401 error and stops retry attempts.
  1. Subscription Cleanup
    • Resets the DisposeBag instances in the deinit method for proper memory management.

Thank you for reading the lengthy article. I would appreciate any comments and feedback on my solution.