swiftalamofirerx-swiftmoya

How to synchronously refresh an access token using Alamofire + RxSwift


I have this generic fetchData() function in my NetworkManager class that is able to request make a authorised request to the network and if it fail (after a number of retries) emits an error that will restart my app (requesting a new login). I need that this retry token be called synchronously, I mean, if multiple requests failed, only one should be requesting the refresh token at once. And if that one fail, and the other one requests must be discarded. I already tried some approached using DispatchGroup / NSRecursiveLock / and also with calling the function cancelRequests describing bellow (in this case, the tasks count is always 0). How can I make this behaviour works in this scenario?


    public func fetchData<Type: Decodable>(fromApi api: TargetType,
                                           decodeFromKeyPath keyPath: String? = nil) -> Single<Response> {
        
        let request = MultiTarget(api)

        return provider.rx.request(request)
                .asRetriableAuthenticated(target: request)
    }

    func cancelAllRequests(){
        if #available(iOS 9.0, *) {
            DefaultAlamofireManager
                .sharedManager
                .session
                .getAllTasks { (tasks) in
                tasks.forEach{ $0.cancel() }
            }
        } else {
            DefaultAlamofireManager
                .sharedManager
                .session
                .getTasksWithCompletionHandler { (sessionDataTask, uploadData, downloadData) in
                    
                sessionDataTask.forEach { $0.cancel() }
                uploadData.forEach { $0.cancel() }
                downloadData.forEach { $0.cancel() }
            }
        }
    }


public extension PrimitiveSequence where TraitType == SingleTrait, ElementType == Response {
    
    private var refreshTokenParameters: TokenParameters {
        TokenParameters(clientId: "pdappclient",
                grantType: "refresh_token",
                refreshToken: KeychainManager.shared.refreshToken)
    }

    func retryWithToken(target: MultiTarget) -> Single<E> {
        self.catchError { error -> Single<Response> in
                    if case Moya.MoyaError.statusCode(let response) = error {
                        if self.isTokenExpiredError(error) {
                            return Single.error(error)
                        } else {
                            return self.parseError(response: response)
                        }
                    }
                    return Single.error(error)
                }
                .retryToken(target: target)
                .catchError { error -> Single<Response> in
                    if case Moya.MoyaError.statusCode(let response) = error {
                        return self.parseError(response: response)
                    }
                    return Single.error(InvalidGrantException())
                }
    }

    private func retryToken(target: MultiTarget) -> Single<E> {
        let maxRetries = 1
        return self.retryWhen({ error in
            error
                    .enumerated()
                    .flatMap { (attempt, error) -> Observable<Int> in
                        if attempt >= maxRetries {
                            return Observable.error(error)
                        }
                        if self.isTokenExpiredError(error) {
                            return Observable<Int>.just(attempt + 1)
                        }
                        return Observable.error(error)
                    }
                    .flatMap { _ -> Single<TokenResponse> in
                        self.refreshTokenRequest()
                    }
                    .share()
                    .asObservable()
        })
    }
    
    private func refreshTokenRequest() -> Single<TokenResponse> {
        return NetworkManager.shared.fetchData(fromApi: IdentityServerAPI
            .token(parameters: self.refreshTokenParameters)).do(onSuccess: { tokenResponse in
                    
            KeychainManager.shared.accessToken = tokenResponse.accessToken
            KeychainManager.shared.refreshToken = tokenResponse.refreshToken
        }, onError: { error in
            NetworkManager.shared.cancelAllRequests()
        })
    }

    func parseError<E>(response: Response) -> Single<E> {
        if response.statusCode == 401 {
            // TODO
        }

        let decoder = JSONDecoder()
        if let errors = try? response.map([BaseResponseError].self, atKeyPath: "errors", using: decoder,
                failsOnEmptyData: true) {
            return Single.error(BaseAPIErrorResponse(errors: errors))
        }

        return Single.error(APIError2.unknown)
    }

    func isTokenExpiredError(_ error: Error) -> Bool {
        if let moyaError = error as? MoyaError {
            switch moyaError {
            case .statusCode(let response):
                if response.statusCode != 401 {
                    return false
                } else if response.data.count == 0 {
                    return true
                }
            default:
                break
            }
        }
        return false
    }

    func filterUnauthorized() -> Single<E> {
        flatMap { (response) -> Single<E> in
            if 200...299 ~= response.statusCode {
                return Single.just(response)
            } else if response.statusCode == 404 {
                return Single.just(response)
            } else {
                return Single.error(MoyaError.statusCode(response))
            }
        }
    }

    func asRetriableAuthenticated(target: MultiTarget) -> Single<Element> {
        filterUnauthorized()
                .retryWithToken(target: target)
                .filterStatusCode()
    }

    func filterStatusCode() -> Single<E> {
        flatMap { (response) -> Single<E> in
            if 200...299 ~= response.statusCode {
                return Single.just(response)
            } else {
                return self.parseError(response: response)
            }
        }
    }
}


Solution

  • I found a solution to my problem using DispatchWorkItem and controlling the entrance on my function with a boolean: isTokenRefreshing. Maybe that's not the most elegant solution, but it works.

    So, in my NetworkManager class I added this two new properties:

    public var savedRequests: [DispatchWorkItem] = []
    public var isTokenRefreshing = false
    

    Now in my SingleTrait extension, whenever I enter in the token refresh method I set the boolean isTokenRefreshing to true. So, if it's true, instead of starting another request, I simply throw a RefreshTokenProcessInProgressException and save the current request in my savedRequests array.

    private func saveRequest(_ block: @escaping () -> Void) {
        // Save request to DispatchWorkItem array
        NetworkManager.shared.savedRequests.append( DispatchWorkItem {
            block()
        })
    }
    

    (Of course, that, if the token refresh succeeds you have to remember to continue all the savedRequests that are saved inside the array, it's not described inside the code down below yet).

    Well, my SingleTrait extension is now something like this:

    import Foundation
    import Moya
    import RxSwift
    import Domain
    
    public extension PrimitiveSequence where TraitType == SingleTrait, ElementType == Response {
        
        private var refreshTokenParameters: TokenParameters {
            TokenParameters(clientId: "pdappclient",
                    grantType: "refresh_token",
                    refreshToken: KeychainManager.shared.refreshToken)
        }
    
        func retryWithToken(target: MultiTarget) -> Single<E> {
            return self.catchError { error -> Single<Response> in
                        if case Moya.MoyaError.statusCode(let response) = error {
                            if self.isTokenExpiredError(error) {
                                return Single.error(error)
                            } else {
                                return self.parseError(response: response)
                            }
                        }
                        return Single.error(error)
                    }
                    .retryToken(target: target)
                    .catchError { error -> Single<Response> in
                        if case Moya.MoyaError.statusCode(let response) = error {
                            return self.parseError(response: response)
                        }
                        return Single.error(error)
                    }
        }
    
        private func retryToken(target: MultiTarget) -> Single<E> {
            let maxRetries = 1
            
            return self.retryWhen({ error in
                error
                        .enumerated()
                        .flatMap { (attempt, error) -> Observable<Int> in
                            if attempt >= maxRetries {
                                return Observable.error(error)
                            }
                            if self.isTokenExpiredError(error) {
                                return Observable<Int>.just(attempt + 1)
                            }
                            return Observable.error(error)
                        }
                        .flatMapFirst { _ -> Single<TokenResponse> in
                            if NetworkManager.shared.isTokenRefreshing {
                                self.saveRequest {
                                    self.retryToken(target: target)
                                }
                                return Single.error(RefreshTokenProcessInProgressException())
                            } else {
                                return self.refreshTokenRequest()
                            }
                        }
                        .share()
                        .asObservable()
            })
        }
        
        private func refreshTokenRequest() -> Single<TokenResponse> {
            NetworkManager.shared.isTokenRefreshing = true
            
            return NetworkManager.shared.fetchData(fromApi: IdentityServerAPI
                .token(parameters: self.refreshTokenParameters))
                .do(onSuccess: { tokenResponse in
                    KeychainManager.shared.accessToken = tokenResponse.accessToken
                    KeychainManager.shared.refreshToken = tokenResponse.refreshToken
                }).catchError { error -> Single<TokenResponse> in
                    return Single.error(InvalidGrantException())
            }
        }
        
        private func saveRequest(_ block: @escaping () -> Void) {
            // Save request to DispatchWorkItem array
            NetworkManager.shared.savedRequests.append( DispatchWorkItem {
                block()
            })
        }
    
        func parseError<E>(response: Response) -> Single<E> {
            if response.statusCode == 401 {
                // TODO
            }
    
            let decoder = JSONDecoder()
            if let errors = try? response.map([BaseResponseError].self, atKeyPath: "errors", using: decoder,
                    failsOnEmptyData: true) {
                return Single.error(BaseAPIErrorResponse(errors: errors))
            }
    
            return Single.error(APIError2.unknown)
        }
    
        func isTokenExpiredError(_ error: Error) -> Bool {
            if let moyaError = error as? MoyaError {
                switch moyaError {
                case .statusCode(let response):
                    if response.statusCode != 401 {
                        return false
                    } else if response.data.count == 0 {
                        return true
                    }
                default:
                    break
                }
            }
            return false
        }
    
        func filterUnauthorized() -> Single<E> {
            flatMap { (response) -> Single<E> in
                if 200...299 ~= response.statusCode {
                    return Single.just(response)
                } else if response.statusCode == 404 {
                    return Single.just(response)
                } else {
                    return Single.error(MoyaError.statusCode(response))
                }
            }
        }
    
        func asRetriableAuthenticated(target: MultiTarget) -> Single<Element> {
            filterUnauthorized()
                    .retryWithToken(target: target)
                    .filterStatusCode()
        }
    
        func filterStatusCode() -> Single<E> {
            flatMap { (response) -> Single<E> in
                if 200...299 ~= response.statusCode {
                    return Single.just(response)
                } else {
                    return self.parseError(response: response)
                }
            }
        }
    }
    

    In my case, if the token refresh fails, after a N number of retries, I restart the app. And so, whenever a restart the application I'm setting the isTokenRefreshing to false again.

    This is the way I found to solve this problem. If you have another approach, please let me know.