iOS rxSwift: retryWhen updating refresh token - ios

I have a static function calling a network service.
When the 400 response code happens I would like to redo the network call.
The current code is working, except that the refreshToken in the header does not update between one try and another.
I think that the problem is because the Observable created but the request function does not update at the retry.
I rode on the web that I should use a deferred method on the Observable, but I don't know how.
I've tried moving the code: headers = [HeaderKeys.refreshToken.rawValue: "test test"] anywhere but still it never makes a call with the "test test" refresh token. it always uses the old one.
How can I fix this?
static func getAccessToken() -> Observable<GetAccessTokenResponse> {
var retryCounter = 0
let maxRetryCounter = 3
let delayRetry = 10.0
guard let refreshToken = NetworkHelper.shared.refreshToken else {
return Observable.error(AuthenticationError.networkError)
}
var headers = [HeaderKeys.refreshToken.rawValue: refreshToken]
return NetworkHelper.shared
.request(url: CoreAPI.accessToken.url, request: nil, headers: headers, responseType: GetAccessTokenResponse.self, method: .get, encoding: nil)
.catchError({ (error) -> Observable<(GetAccessTokenResponse?, Int)> in
return Observable.error(AuthenticationError.networkError)
})
.flatMap({ (response) -> Observable<GetAccessTokenResponse> in
// check http status code
switch response.1 {
case 200:
guard response.0?.accessToken != nil else {
return Observable.error(AuthenticationError.genericError)
}
// success
return Observable.just(response.0!)
case 400:
// invalid parameters, refresh token not existing
return Observable.error(AuthenticationError.invalidParameters)
case 404:
// user not existing
return Observable.error(AuthenticationError.userDoesntExist)
default:
// by default return network error
return Observable.error(AuthenticationError.networkError)
}
})
.retryWhen({ (errors) -> Observable<Void> in
return errors
.do(onNext: { (error) in
headers = [HeaderKeys.refreshToken.rawValue: "test test"]
})
.flatMap({error -> Observable<Int> in
debugLog("Retrying get refresh token")
if retryCounter >= maxRetryCounter {
let authError = error as? AuthenticationError ?? .genericError
if authError == AuthenticationError.invalidParameters {
// publish logged false on subject
VDAAuthenticationManager.shared.logged.onNext(false)
}
return Observable.error(error)
}
// increase the retry counter and retry
retryCounter += 1
return Observable<Int>.timer(delayRetry, scheduler: MainScheduler.instance)
})
.flatMap ({ (_) -> Observable<Void> in
return Observable.just(())
})
})
}

In the article RxSwift and Retrying a Network Request Despite Having an Invalid Token I explain how to keep and update a token and how to handle retries when you get a 401 error. Using deferred is part of the answer.
In your particular case. It looks like you could use my service like this:
func getToken(lastResponse: GetAccessTokenResponse?) -> Observable<(response: HTTPURLResponse, data: Data)> {
guard let refreshToken = lastResponse?.refreshToken else { return Observable.error(AuthenticationError.networkError) }
var request = URLRequest(url: CoreAPI.accessToken.url)
request.addValue(refreshToken, forHTTPHeaderField: HeaderKeys.refreshToken.rawValue)
return URLSession.shared.rx.response(request: request)
}
func extractToken(data: Data) throws -> GetAccessTokenResponse {
return try JSONDecoder().decode(GetAccessTokenResponse.self, from: data)
}
let tokenService = TokenAcquisitionService(initialToken: nil, getToken: getToken, extractToken: extractToken(data:))
In the above, you will have to pass a valid initialToken instead of nil or you will have to modify the getToken so it can get a token even if it doesn't have a refresh token.
An example of how to use deferred is below:
let response = Observable
.deferred { tokenAcquisitionService.token.take(1) }
.flatMap { makeRequest(withToken: $0) }
.map { response in
guard response.response.statusCode != 401 else { throw ResponseError.unauthorized }
return response
}
.retryWhen { $0.renewToken(with: tokenAcquisitionService) }
I explain in the article what each line of code is for and how it works.

Related

FlatMap with Generic ReturnType using Combine

I'm building a network API.
I'm new to Combine and I'm having some troubles with it, I'm trying to chain publish network requests, in this case I'm forming an URLRequest publisher and dispatching it on another publisher, the problem is that I cant make the flatMap work on the second publisher.
First I assemble the URLRequest with the Auth token:
func asURLRequest(baseURL: String) -> AnyPublisher<URLRequest, NetworkRequestError> {
return Deferred {
Future<URLRequest, NetworkRequestError> { promise in
if var urlComponents = URLComponents(string: baseURL) {
urlComponents.path = "\(urlComponents.path)\(path)"
urlComponents.queryItems = queryItemsFrom(params: queryParams)
if let finalURL = urlComponents.url {
if let user = Auth.auth().currentUser {
print("##### final url -> \(finalURL)")
// Retrieves the Firebase authentication token, possibly refreshing it if it has expired.
user.getIDToken(completion: { (token, error) in
if let fbToken = token {
var request = URLRequest(url: finalURL)
request.httpMethod = method.rawValue
request.httpBody = requestBodyFrom(params: body)
let defaultHeaders: HTTPHeaders = [
HTTPHeaderField.contentType.rawValue: contentType.rawValue,
HTTPHeaderField.acceptType.rawValue: contentType.rawValue,
HTTPHeaderField.authentication.rawValue: fbToken
]
request.allHTTPHeaderFields = defaultHeaders.merging(headers ?? [:], uniquingKeysWith: { (first, _) in first })
print("##### API TOKEN() SUCCESS: \(defaultHeaders)")
promise(.success(request))
}
if let fbError = error {
print("##### API TOKEN() ERROR: \(fbError)")
promise(.failure(NetworkRequestError.decodingError))
}
})
}
} else {
promise(.failure(NetworkRequestError.decodingError))
}
} else {
promise(.failure(NetworkRequestError.decodingError))
}
}
}.eraseToAnyPublisher()
}
Then I'm trying to dispatch a request (publisher) and return another publisher, the problem is that the .flatMap is not getting called:
struct APIClient {
var baseURL: String!
var networkDispatcher: NetworkDispatcher!
init(baseURL: String,
networkDispatcher: NetworkDispatcher = NetworkDispatcher()) {
self.baseURL = baseURL
self.networkDispatcher = networkDispatcher
}
/// Dispatches a Request and returns a publisher
/// - Parameter request: Request to Dispatch
/// - Returns: A publisher containing decoded data or an error
func dispatch<R: Request>(_ request: R) -> AnyPublisher<R.ReturnType, NetworkRequestError> {
print("##### --------> \(request)")
//typealias RequestPublisher = AnyPublisher<R.ReturnType, NetworkRequestError>
return request.asURLRequest(baseURL: baseURL)
.flatMap { request in
//NOT GETTING CALLED
self.networkDispatcher.dispatch(request: request)
}.eraseToAnyPublisher()
}
The final publisher that is not being called is the following:
struct NetworkDispatcher {
let urlSession: URLSession!
public init(urlSession: URLSession = .shared) {
self.urlSession = urlSession
}
/// Dispatches an URLRequest and returns a publisher
/// - Parameter request: URLRequest
/// - Returns: A publisher with the provided decoded data or an error
func dispatch<ReturnType: Codable>(request: URLRequest) -> AnyPublisher<ReturnType, NetworkRequestError> {
return urlSession
.dataTaskPublisher(for: request)
// Map on Request response
.tryMap({ data, response in
// If the response is invalid, throw an error
if let response = response as? HTTPURLResponse,
!(200...299).contains(response.statusCode) {
throw httpError(response.statusCode)
}
// Return Response data
return data
})
// Decode data using our ReturnType
.decode(type: ReturnType.self, decoder: JSONDecoder())
// Handle any decoding errors
.mapError { error in
handleError(error)
}
// And finally, expose our publisher
.eraseToAnyPublisher()
}
}
Running the code:
struct ReadUser: Request {
typealias ReturnType = UserData
var path: String
var method: HTTPMethod = .get
init(_ id: String) {
path = "users/\(id)"
}
}
let apiClient = APIClient(baseURL: BASE_URL)
var cancellables = [AnyCancellable]()
apiClient.dispatch(ReadUser(Auth.auth().currentUser?.uid ?? ""))
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { result in
switch result {
case .failure(let error):
// Handle API response errors here (WKNetworkRequestError)
print("##### Error loading data: \(error)")
default: break
}
},
receiveValue: { value in
})
.store(in: &cancellables)
I took your code and boiled it down to just the Combine parts. I could not reproduce the issue you are describing. I'll post that code below. I recommend you start simplifying your code a bit at a time to see if that helps. Factoring out the Auth and Facebook token code seems like a good candidate to start with. Another good debugging technique might be to put in more explicit type declarations to make sure your closures are taking and returning what you expect. (just the other day I had a map that I thought I was applying to an Array when I was really mapping over Optional).
Here's the playground:
import UIKit
import Combine
func asURLRequest(baseURL: String) -> AnyPublisher<URLRequest, Error> {
return Deferred {
Future<URLRequest, Error> { promise in
promise(.success(URLRequest(url: URL(string: "https://www.apple.com")!)))
}
}.eraseToAnyPublisher()
}
struct APIClient {
var networkDispatcher: NetworkDispatcher!
init(networkDispatcher: NetworkDispatcher = NetworkDispatcher()) {
self.networkDispatcher = networkDispatcher
}
func dispatch() -> AnyPublisher<Data, Error> {
return asURLRequest(baseURL: "Boo!")
.flatMap { (request: URLRequest) -> AnyPublisher<Data, Error> in
print("Request Received. \(String(describing: request))")
return self.networkDispatcher.dispatch(request: request)
}.eraseToAnyPublisher()
}
}
func httpError(_ code: Int) -> Error {
return NSError(domain: "Bad Things", code: -1, userInfo: nil)
}
func handleError(_ error: Error) -> Error {
debugPrint(error)
return error
}
struct NetworkDispatcher {
let urlSession: URLSession!
public init(urlSession: URLSession = .shared) {
self.urlSession = urlSession
}
func dispatch(request: URLRequest) -> AnyPublisher<Data, Error> {
return urlSession
.dataTaskPublisher(for: request)
.tryMap({ data, response in
if let response = response as? HTTPURLResponse,
!(200...299).contains(response.statusCode) {
throw httpError(response.statusCode)
}
// Return Response data
return data
})
.mapError { error in
handleError(error)
}
.eraseToAnyPublisher()
}
}
let apiClient = APIClient()
var cancellables = [AnyCancellable]()
apiClient.dispatch()
.print()
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { result in
debugPrint(result)
switch result {
case .failure(let error):
// Handle API response errors here (WKNetworkRequestError)
print("##### Error loading data: \(error)")
default: break
}
},
receiveValue: { value in
debugPrint(value)
})
.store(in: &cancellables)
I refactored your code. Breaking down the offending method into several functions. I could not find any problem. Below is my refactoring. You will notice that I broke all the code that constructs things into their own functions so they can be easily tested without dealing with the effect (I don't even have to mock the effect to test the logic.)
extension Request {
func asURLRequest(baseURL: String) -> AnyPublisher<URLRequest, NetworkRequestError> {
guard let user = Auth.auth().currentUser else {
return Fail(error: NetworkRequestError.missingUser)
.eraseToAnyPublisher()
}
return user.idTokenPublisher()
.catch { error in
Fail(error: NetworkRequestError.badToken(error))
}
.tryMap { token in
makeRequest(
finalURL: try finalURL(baseURL: baseURL),
fbToken: token
)
}
.eraseToAnyPublisher()
}
func finalURL(baseURL: String) throws -> URL {
guard var urlComponents = URLComponents(string: baseURL) else {
throw NetworkRequestError.malformedURLComponents
}
urlComponents.path = "\(urlComponents.path)\(path)"
urlComponents.queryItems = queryItemsFrom(params: queryParams)
guard let result = urlComponents.url else {
throw NetworkRequestError.malformedURLComponents
}
return result
}
func makeRequest(finalURL: URL, fbToken: String) -> URLRequest {
var request = URLRequest(url: finalURL)
request.httpMethod = method.rawValue
request.httpBody = requestBodyFrom(params: body)
let defaultHeaders: HTTPHeaders = [
HTTPHeaderField.contentType.rawValue: contentType.rawValue,
HTTPHeaderField.acceptType.rawValue: contentType.rawValue,
HTTPHeaderField.authentication.rawValue: fbToken
]
request.allHTTPHeaderFields = defaultHeaders.merging(
headers ?? [:],
uniquingKeysWith: { (first, _) in first }
)
return request
}
}
extension User {
func idTokenPublisher() -> AnyPublisher<String, Error> {
Deferred {
Future { promise in
getIDToken(completion: { token, error in
if let token = token {
promise(.success(token))
}
else {
promise(.failure(error ?? UnknownError()))
}
})
}
}
.eraseToAnyPublisher()
}
}
struct UnknownError: Error { }

Refresh Token with Alamofire retry count and retry request swift

I am using Alamofire to integrate API calls, handling error code and specially status code error like 401 and 403. I have also created the getRefreshToken() function, if error comes it will refresh the token.
Problem I am facing about Alamofire.retryCount and repeat the request in the right way? I have seen different references but I cannot figure out How I integrate in my main method.
Updated: added getRefreshToken() code.
My Code:
#objc private func getDataFromWeb(params:NSMutableDictionary,
callback:#escaping (_ success:Bool, _ result:Any?)->(Bool)) -> Void {
var method = HTTPMethod.get
var encoding = URLEncoding.default as ParameterEncoding
if(params["Method"] as! String == "POST"){
method = HTTPMethod.post
encoding = Alamofire.JSONEncoding.default
}
Alamofire.request(url,
method:method,
parameters:pr,
encoding:encoding,
headers:[ "Accept":"application/json", "Authorization":"Bearer \(token ?? "")"])
.downloadProgress(closure: { (progress) in
//progress closure
})
.validate(statusCode: 200..<300)
.response { response in
print(response.error?.localizedDescription)
var code = response.response?.statusCode
if code == 401 || code == 403{
self.getRefreshToken() // calling refresh token method
} else {
if(callback(response.data?.count != 0, response.data)){
}
}
}
}
getRefreshToken Function:
func getRefreshToken() {
DataProvider.main.serviceLogin(username:User, password:Pass, firmNo: FirmId , callback:{success, result in
do{
if(success){
let model = try JSONDecoder().decode(Login.self, from: result as! Data)
if model.isSuccess == true {
DataProvider.main.token = model.token
}
return true
} else{
return false
}
}catch let e {
print(e)
return false
}
})
}
References:
https://stackoverflow.com/questions/58496713/retry-the-old-request-with-new-refresh-token-in-swift-alamofire
https://stackoverflow.com/questions/52287882/right-way-to-refresh-the-token

Refresh access token with URLSession after getting a 401 response code & retry request

I'm working on building a networking client for my iOS application which uses OAuth 2.0 Authorization techniques (Access & Refresh Token). There is a feature for my networking client that I have been struggling to implement:
When a 401 error occurs that means the Access Token has expired and I need to send a Refresh Token over to my server to obtain a new Access Token.
After getting a new Access Token I need to redo the previous request that got the 401 error.
So far I have written this code for my networking client:
typealias NetworkCompletion = Result<(Data, URLResponse), FRNetworkingError>
/// I am using a custom result type to support just an Error and not a Type object for success
enum NetworkResponseResult<Error> {
case success
case failure(Error)
}
class FRNetworking: FRNetworkingProtocol {
fileprivate func handleNetworkResponse(_ response: HTTPURLResponse) -> NetworkResponseResult<Error> {
switch response.statusCode {
case 200...299: return .success
case 401: return .failure(FRNetworkingError.invalidAuthToken)
case 403: return .failure(FRNetworkingError.forbidden)
case 404...500: return .failure(FRNetworkingError.authenticationError)
case 501...599: return .failure(FRNetworkingError.badRequest)
default: return .failure(FRNetworkingError.requestFailed)
}
}
func request(using session: URLSession = URLSession.shared, _ endpoint: Endpoint, completion: #escaping(NetworkCompletion) -> Void) {
do {
try session.dataTask(with: endpoint.request(), completionHandler: { (data, response, error) in
if let error = error {
print("Unable to request data \(error)")
// Invoke completion for error
completion(.failure(.unknownError))
} else if let data = data, let response = response {
// Passing Data and Response into completion for parsing in ViewModels
completion(.success((data, response)))
}
}).resume()
} catch {
print("Failed to execute request", error)
completion(.failure(.requestFailed))
}
}
}
Endpoint is just a struct that builds a URLRequest:
struct Endpoint {
let path: String
let method: HTTPMethod
let parameters: Parameters?
let queryItems: [URLQueryItem]?
let requiresAuthentication: Bool
var url: URL? {
var components = URLComponents()
components.scheme = "http"
components.host = "127.0.0.1"
components.port = 8000
components.path = "/api\(path)"
components.queryItems = queryItems
return components.url
}
func request() throws -> URLRequest {
/// Creates a request based on the variables per struct
}
}
Where do I put the code that allows the FRNetworking.request() to get a new token and retry the request?
I have done the following inside the else if let data = data, let response = response statement:
if let response = response as? HTTPURLResponse {
let result = self.handleNetworkResponse(response)
switch result {
case .failure(FRNetworkingError.invalidAuthToken):
break
// TODO: Get new Access Token and refresh?
default:
break
}
}
Is this the right approach to refresh the token and redo the API call or is there a better way?
You have to write a function that updates the token and, depending on the result, returns true or false
private func refreshAccessToken(completion: #escaping (Bool) -> Void {
// Make a request to refresh the access token
// Update the accessToken and refreshToken variables when the request is completed
// Call completion(true) if the request was successful, completion(false) otherwise
}
Declare 2 variables at the beginning of the class
var session: URLSession
var endpoint: Endpoint
Inside the case .failure assign these variables
session = session
endpoint = endpoint
Then call refreshAccessToken method. The final code will look like this
if let response = response as? HTTPURLResponse {
let result = self.handleNetworkResponse(response)
switch result {
case .failure(FRNetworkingError.invalidAuthToken):
session = session
endpoint = endpoint
self?.refreshAccessToken { success in
if success {
self?.request(using: session, endpoint, completion: completion)
} else {
completion(.failure(.unknownError))
}
}
break
default:
break
}
}

How to retry request with Alamofire?

Is there a way, in Alamofire, to re-send the request if the response code from the first request is 401, where I can refresh the token and retry my request again?
The problem is that I'm using MVVM and also completion handler already.
In my ViewModel the request function looks like:
public func getProfile(completion: #escaping (User?) -> Void) {
guard let token = UserDefaults.standard.value(forKey: Constants.shared.tokenKey) else { return }
let headers = ["Authorization": "Bearer \(token)"]
URLCache.shared.removeAllCachedResponses()
Alamofire.request(Constants.shared.getProfile, method: .get, parameters: nil, encoding: URLEncoding.default, headers: headers).responseJSON { (response) in
switch response.result {
case .success:
guard let data = response.data else { return }
if JSON(data)["code"].intValue == 401 {
// here I need to refresh my token and re-send the request
} else {
let user = User(json: JSON(data)["data"])
completion(user)
}
completion(nil)
case .failure(let error):
print("Failure, ", error.localizedDescription)
completion(nil)
}
}
}
and from my ViewController I call it like:
viewModel.getProfile { (user) in
if let user = user {
...
}
}
So I do not know how can retry my request without using a new function, so I can still get my user response from completion part in my ViewController.
Maybe someone can show me the right path.
Thanks in advance!
To retry a request create a Request wrapper and use the RequestInterceptor protocol of Alamofire like this
final class RequestInterceptorWrapper: RequestInterceptor {
// Retry your request by providing the retryLimit. Used to break the flow if we get repeated 401 error
var retryLimit = 0
func retry(_ request: Request, for session: Session, dueTo error: Error, completion: #escaping (RetryResult) -> Void) {
guard let statusCode = request.response?.statusCode else { return }
switch statusCode {
case 200...299:
completion(.doNotRetry)
default:
if request.retryCount < retryLimit {
completion(.retry)
return
}
completion(.doNotRetry)
}
}
//This method is called on every API call, check if a request has to be modified optionally
func adapt(_ urlRequest: URLRequest, for session: Session, completion: #escaping (Result<URLRequest, Error>) -> Void) {
//Add any extra headers here
//urlRequest.addValue(value: "", forHTTPHeaderField: "")
completion(.success(urlRequest))
}
}
Usage: For every API request, the adapt() method is called, and on validate() the retry method is used to validate the status code. retryLimit can be set by creating an instance of the interceptor here
Providing the retryLimit would call the API twice if the response was an error
let interceptor = RequestInterceptorWrapper()
func getDataFromAnyApi(completion: #escaping (User?) -> Void)) {
interceptor.retryLimit = 2
AF.request(router).validate().responseJSON { (response) in
guard let data = response.data else {
completion(nil)
return
}
// convert to User and return
completion(User)
}
}
Yes you can on Alamofire 4.0
The RequestRetrier protocol allows a Request that encountered an Error while being executed to be retried. When using both the RequestAdapter and RequestRetrier protocols together, you can create credential refresh systems for OAuth1, OAuth2, Basic Auth and even exponential backoff retry policies. The possibilities are endless. Here's an example of how you could implement a refresh flow for OAuth2 access tokens.
func should(_ manager: SessionManager, retry request: Request, with error: Error, completion: #escaping RequestRetryCompletion) {
lock.lock() ; defer { lock.unlock() }
if let response = request.task?.response as? HTTPURLResponse, response.statusCode == 401 {
requestsToRetry.append(completion)
if !isRefreshing {
refreshTokens { [weak self] succeeded, accessToken, refreshToken in
guard let strongSelf = self else { return }
strongSelf.lock.lock() ; defer { strongSelf.lock.unlock() }
if let accessToken = accessToken, let refreshToken = refreshToken {
strongSelf.accessToken = accessToken
strongSelf.refreshToken = refreshToken
}
strongSelf.requestsToRetry.forEach { $0(succeeded, 0.0) }
strongSelf.requestsToRetry.removeAll()
}
}
} else {
completion(false, 0.0)
}
}
Reference: AlamofireDocumentation
you can add interceptor
Alamofire.request(Constants.shared.getProfile, method: .get, parameters: nil, encoding: URLEncoding.default, headers: headers)
add the protocol RequestInterceptor
then implement this two protocol method
// retryCount number of time api need to retry
func adapt(_ urlRequest: URLRequest, for session: Session, completion: #escaping (Result<URLRequest, Error>) -> Void) {
completion(.success(urlRequest))
}
func retry(_ request: Request, for session: Session, dueTo error: Error, completion: #escaping (RetryResult) -> Void) {
guard request.retryCount < retryCount else {
completion(.doNotRetry)
return
}
/// Call UR API here
}
once api get fail this two method call, do
Could you just recursively call the function if it receives a 401? You would definitely need to create some type of exit condition so that if it continues to fail that it will break out, but it seems to me that it would work.

How to restart a net request with moya ,rxswift

I want to handle each request, and if the response of the request not match the condition , start a new request and get the response. How can I restart the old request
here is my code now
static func request(target: API) -> Observable<Response> {
let actualRequest = provider.request(target)
return self.provider.request(target).flatMapLatest { (response) -> Observable<Response> in
let responseModel = ResponseModel(data:response.data)
if responseModel.code == -405 {
let refreshToken = User.shared?.refreshToken
self.provider.request(.refreshToken(refresh: refreshToken!)).flatMap({ response -> Observable<String> in
return Observable.just("")
}).shareReplay(1).subscribe(onNext: { refreshToken in
// here I get a new token, how to retry the actualRequest , or how to start a new network request with the target
}, onError: { (error) in
},onCompleted: { _ in
})
}
return Observable.just(response)
}
}
You should subclass MoyaProvider, override request method and there you can check response from the api, and retry if needed.

Resources