Pankaj Gaikar
Pankaj Gaikar

Reputation: 2483

Swift - Combine - Synchronous API execution inside .tryCatch

I am trying to work around 401 error scenario where I want to catch the error, check if error is 401 and if it is,

  1. Refresh oAuth
  2. Execute same API again.

Currently, I am doing something like below:

return urlSession.dataTaskPublisher(for: request)
    .tryMap(checkForAPIError)
    .tryCatch { (error) -> AnyPublisher<(data: serverData, response: URLResponse), URLError> in
        self.fetchoAuthToken()
            .tryMap { (token) in
                // Saves token
            }
            .receive(on: RunLoop.main)
            .subscribe(on: DispatchQueue.main)
            .sink { (completion) in
                 // Completion handling here
            } receiveValue: { (value) in
                print("Received \(value)")
            }
            .store(in: &self.subscription)

        return self.urlSession.dataTaskPublisher(for: request)
    }
    .tryMap(parseJson)
    .retry(3)
    .receive(on: RunLoop.main)
    .eraseToAnyPublisher()

The current problem for me is, while API self.fetchoAuthToken() is still in execution, block returns new request. Which then executes with old tokens.

I want for self.fetchoAuthToken() to execute synchronously so return can be done after it executes and new tokens can be used.

Any help would be appreciated.

Upvotes: 1

Views: 1834

Answers (2)

Cristik
Cristik

Reputation: 32853

As others have said, the side effects (e.g. saving the token) should not be part of the processing pipeline (or at least not directly).

Recommending to split the request logic in two, by adding a few helper functionalities:

/// this will do the actual URLSession call
/// fetchoAuthToken will likely call this with `withToken: false)
func _sendRequest(_ request: URLRequest, withToken: Bool = true) -> AnyPublisher<(data: Data, response: URLResponse), Error> {
    let request = withToken ? addToken(to: request) : request
    return urlSession
        .dataTaskPublisher(for: request)
        .mapError { $0 }
        .eraseToAnyPublisher()
}

/// name explicit enough :)
func addToken(to request: URLRequest) -> URLRequest {
    // do whathever needed (headers, cookies, etc)
}

enum MyError: Error {
    /// `fetchoAuthToken` and `checkForAPIError` should return this
    /// in case the request fails with a 401/403, or maybe some other
    /// conditions
    case notAuthenticated
}

Now, if you encapsulate the token fetching and saving within fetchoAuthToken, and implement checkForAPIError to return MyError.notAuthenticated in case the request failed due to that reason, then you can end up with a nice "pure" pipeline:

return _sendRequest(request)
    .tryMap(checkForAPIError)
    .tryCatch { error -> AnyPublisher<(data: Data, response: URLResponse), Error> in
        if error as? MyError == .notAuthenticated {
            return fetchToken().flatMap { _sendRequest(request) }.eraseToAnyPublisher()
        } else {
            throw error
        }
    }
    .tryMap(parseJson)
    .retry(3, unless: \.isNotAuthenticated)
    .receive(on: RunLoop.main)
    .eraseToAnyPublisher()

Another problem that you might want to address is the retry part - if fetchoAuthToken fails due to invalid credentials, then you'll likely want to skip the retry part, as if the credentials are invalid the first time, most likely they also will be invalid the second and third time.

For this problem, you can use this answer, and do a retry unless:


extension Error {
    /// enabling sugar syntax for `retry`
    var isNotAuthenticated: Bool { self as? MyError == .notAuthenticated }
}

return _sendRequest(request)
     // rest of the pipeline omitted for readability
    .retry(3, unless: \.isNotAuthenticated)

Upvotes: 0

New Dev
New Dev

Reputation: 49590

You need to chain the publishers, and return the chain as a new publisher from tryCatch.

You should typically avoid side-effects, but if you must - like saving the OAuth token, do that in .handleEvents, instead of creating a sink subscription.

return urlSession.dataTaskPublisher(for: request)
    .tryMap(checkForAPIError)
    .tryCatch { error in
        self.fetchoAuthToken()
            .handleEvents(receiveOutput: { (token) in
                // Saves token
            })
            .flatMap { _ in 
                urlSession.dataTaskPublisher(for: request) 
            }
    }
    .tryMap(parseJson)
    .retry(3)
    .receive(on: RunLoop.main)
    .eraseToAnyPublisher()

Upvotes: 1

Related Questions