Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

401 retry mechanism using Combine Publishers

Fairly new to Combine. A common scenario using access tokens and refresh token.

You get a 401 and you need to handle it (call some service to refresh the token) before retrying the initial call again

func dataLoader(backendURL: URL) -> AnyPublisher<Data, Error> {
    let request = URLRequest(url: backendURL)
    return dataPublisher(for: request)
        // We get here when a request fails
        .tryCatch { (error) -> AnyPublisher<(data: Data, response: URLResponse), URLError> in
          guard error.errorCode == 401 else {  // UPS - Unauthorized request
                throw error
            }

          // We need to refresh token and retry -> HOW?
          // And try again 
          // return dataPublisher(for: request) 
        }
        .tryMap { data, response -> Data in
            guard let httpResponse = response as? HTTPURLResponse,
                httpResponse.statusCode == 200 else {

                throw CustomError.invalidServerResponse
            }
            return data
        }
        .eraseToAnyPublisher()
}

How would I go about wrapping this "token refresh service"?

like image 357
Gizmo_Jon Avatar asked Dec 31 '22 03:12

Gizmo_Jon


1 Answers

Your code mentions a “token” but you don't explain what that is. Let's assume you have a token type:

struct Token: RawRepresentable {
    var rawValue: String
}

And let's say you have a function that gets a fresh token asynchronously, by returning a publisher of the fresh token:

func freshToken() -> AnyPublisher<Token, Error> {
    // Your code here, probably involving a URL request/response...
    fatalError()
}

And let's say you generate the URL request for the data by combining some URL with the token:

func backendRequest(with url: URL, token: Token) -> URLRequest {
    // Your code here, to somehow combine the url and the token into the real ...
    fatalError()
}

Now you want to retry the request, with a fresh token each time, if the response is a 404. You should probably limit the number of tries. So let's write the function to take a triesLeft count. If triesLeft > 1 and the response is 404, it will ask for a fresh token and use that to call itself again (with triesLeft decremented).

The goal is made more complex because URLSession.DataTaskPublisher doesn't turn a 404 response into an error. It treats it as a normal output.

So we'll use a nested helper function to process the output of the DataTaskPublisher, so that we don't have so much code nested inside closures. The helper function, named publisher(forDataTaskOutput:), decides what to do based on the response.

  • If the response is an HTTP response with code 200, it just returns the data. Note that it has to return a publisher whose Failure is Error, so it uses a Result.Pubilsher and lets Swift deduce the Failure type.

  • If the response is an HTTP response with code 404, and triesLeft > 1, it calls freshToken and uses flatMap to chain that into another call to the outer function.

  • Otherwise, it produces a failure with error CustomError.invalidServerResponse.

func data(atBackendURL url: URL, token: Token, triesLeft: Int) -> AnyPublisher<Data, Error> {
    func publisher(forDataTaskOutput output: URLSession.DataTaskPublisher.Output) -> AnyPublisher<Data, Error> {
        switch (output.response as? HTTPURLResponse)?.statusCode {
        case .some(200):
            return Result.success(output.data).publisher.eraseToAnyPublisher()
        case .some(404) where triesLeft > 1:
            return freshToken()
                .flatMap { data(atBackendURL: url, token: $0, triesLeft: triesLeft - 1) }
                .eraseToAnyPublisher()
        default:
            return Fail(error: CustomError.invalidServerResponse).eraseToAnyPublisher()
        }
    }

    let request = backendRequest(with: url, token: token)
    return URLSession.shared.dataTaskPublisher(for: request)
        .mapError { $0 as Error }
        .flatMap(publisher(forDataTaskOutput:))
        .eraseToAnyPublisher()
}
like image 88
rob mayoff Avatar answered Jan 05 '23 15:01

rob mayoff