Search code examples
swiftrx-swifturlsessionrx-cocoa

Handling 401 status w/ RxSwift & URLSession


I currently have a network client that looks like the below:

class Client<R: ResourceType> {

    let engine: ClientEngineType
    var session: URLSession

    init(engine: ClientEngineType = ClientEngine()) {
        self.engine = engine
        self.session = URLSession.shared
    }

    func request<T: Codable>(_ resource: R) -> Single<T> {
        let request = URLRequest(resource: resource)

        return Single<T>.create { [weak self] single in
            guard let self = self else { return Disposables.create() }
            let response = self.session.rx.response(request: request)

            return response.subscribe(
                onNext: { response, data in

                    if let error = self.error(from: response) {
                        single(.error(error))
                        return
                    }

                    do {
                        let decoder = JSONDecoder()
                        let value = try decoder.decode(T.self, from: data)
                        single(.success(value))
                    } catch let error {
                        single(.error(error))
                    }
            },
                onError: { error in
                    single(.error(error))
            })
        }
    }

    struct StatusCodeError: LocalizedError {
        let code: Int

        var errorDescription: String? {
            return "An error occurred communicating with the server. Please try again."
        }
    }

    private func error(from response: URLResponse?) -> Error? {
        guard let response = response as? HTTPURLResponse else { return nil }

        let statusCode = response.statusCode

        if 200..<300 ~= statusCode {
            return nil
        } else {
            return StatusCodeError(code: statusCode)
        }
    }
}

Which I can then invoke something like

let client = Client<MyRoutes>()
client.request(.companyProps(params: ["collections": "settings"]))
    .map { props -> CompanyModel in return props }
    .subscribe(onSuccess: { props in
      // do something with props
    }) { error in
        print(error.localizedDescription)
}.disposed(by: disposeBag)

I'd like to start handling 401 responses and refreshing my token and retrying the request.

I'm struggling to find a nice way to do this.

I found this excellent gist that outlines a way to achieve this, however I am struggling to implement this in my current client.

Any tips or pointers would be very much appreciated.


Solution

  • That's my gist! (Thanks for calling it excellent.) Did you see the article that went with it? https://medium.com/@danielt1263/retrying-a-network-request-despite-having-an-invalid-token-b8b89340d29

    There are two key elements in handling 401 retries. First is that you need a way to insert tokens into your requests and start your request pipeline with Observable.deferred { tokenAcquisitionService.token.take(1) }. In your case, that means you need a URLRequest.init that will accept a Resource and a token, not just a resource.

    The second is to throw a TokenAcquisitionError.unauthorized error when you get a 401 and end your request pipeline with .retryWhen { $0.renewToken(with: tokenAcquisitionService) }

    So, given what you have above, in order to handle token retries all you need to do is bring my TokenAcquisitionService into your project and use this:

    func getToken(_ oldToken: Token) -> Observable<(response: HTTPURLResponse, data: Data)> {
        fatalError("this function needs to be able to request a new token from the server. It has access to the old token if it needs that to request the new one.")
    }
    
    func extractToken(_ data: Data) -> Token {
        fatalError("This function needs to be able to extract the new token using the data returned from the previous function.")
    }
    
    let tokenAcquisitionService = TokenAcquisitionService<Token>(initialToken: Token(), getToken: getToken, extractToken: extractToken)
    
    final class Client<R> where R: ResourceType {
        let session: URLSession
    
        init(session: URLSession = URLSession.shared) {
            self.session = session
        }
    
        func request<T>(_ resource: R) -> Single<T> where T: Decodable {
            return Observable.deferred { tokenAcquisitionService.token.take(1) }
                .map { token in URLRequest(resource: resource, token: token) }
                .flatMapLatest { [session] request in session.rx.response(request: request) }
                .do(onNext: { response, _ in
                    if response.statusCode == 401 {
                        throw TokenAcquisitionError.unauthorized
                    }
                })
                .map { (_, data) -> T in
                    return try JSONDecoder().decode(T.self, from: data)
            }
            .retryWhen { $0.renewToken(with: tokenAcquisitionService) }
            .asSingle()
        }
    }
    

    Note, it could be the case that the getToken function has to, for example, present a view controller that asks for the user's credentials. That means you need to present your login view controller (or a UIAlertController) to gather the data. Or maybe you get both an authorization token and a refresh token from your server when you login. In that case the TokenAcquisitionService should hold on to both of them (i.e., its T should be a (token: String, refresh: String). Either is fine.

    The only problem with the service is that if acquiring the new token fails, the entire service shuts down. I haven't fixed that yet.