Skip to content

Feature - Request Adapter and Retrier System #1450

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Sep 6, 2016

Conversation

cnoon
Copy link
Member

@cnoon cnoon commented Sep 4, 2016

This PR adds a couple powerful protocols, RequestAdapter and RequestRetrier, that open up all sorts of possibilities including:

  • Easing Request modifications for Authorization headers
  • Retrying requests that encountered an error with a custom exponential backoff retry policy
  • Completely thread-safe refresh systems for web services behind OAuth2 authentication

I'm sure there are all sorts of other cool things you could do with this, but these are the main things I had in mind when putting this together.

RequestAdapter

The RequestAdapter protocol allows each Request made on a SessionManager to be inspected and adapted before being created. One very specific way to use an adapter is to append an Authorization header to requests behind a certain type of authentication.

class AccessTokenAdapter: RequestAdapter {
    private let accessToken: String

    init(accessToken: String) {
        self.accessToken = accessToken
    }

    func adapt(_ urlRequest: URLRequest) -> URLRequest {
        var urlRequest = urlRequest

        if urlRequest.urlString.hasPrefix("https://httpbin.org") {
            urlRequest.setValue("Bearer " + accessToken, forHTTPHeaderField: "Authorization")
        }

        return urlRequest
    }
}

let sessionManager = SessionManager()
sessionManager.adapter = AccessTokenAdapter(accessToken: "1234")

sessionManager.request("https://httpbin.org/get", withMethod: .get)

RequestRetrier

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 a short example of how you could implement a refresh flow for OAuth2 access tokens.

class OAuth2Handler: RequestAdapter, RequestRetrier {
    private typealias RefreshCompletion = (_ succeeded: Bool, _ accessToken: String?, _ refreshToken: String?) -> Void

    private let sessionManager: SessionManager = {
        let configuration = URLSessionConfiguration.default
        configuration.httpAdditionalHeaders = SessionManager.defaultHTTPHeaders

        return SessionManager(configuration: configuration)
    }()

    private let lock = NSLock()

    private var clientID: String
    private var baseURLString: String
    private var accessToken: String
    private var refreshToken: String

    private var isRefreshing = false
    private var requestsToRetry: [RequestRetryCompletion] = []

    // MARK: - Initialization

    public init(clientID: String, baseURLString: String, accessToken: String, refreshToken: String) {
        self.clientID = clientID
        self.baseURLString = baseURLString
        self.accessToken = accessToken
        self.refreshToken = refreshToken
    }

    // MARK: - RequestAdapter

    public func adapt(_ urlRequest: URLRequest) -> URLRequest {
        if urlRequest.urlString.hasPrefix(baseURLString) {
            var mutableURLRequest = urlRequest
            mutableURLRequest.setValue("Bearer " + accessToken, forHTTPHeaderField: "Authorization")
            return mutableURLRequest
        }

        return urlRequest
    }

    // MARK: - RequestRetrier

    public func should(_ manager: SessionManager, retry request: Request, with error: Error, completion: 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)
        }
    }

    // MARK: - Private - Refresh Tokens

    private func refreshTokens(completion: RefreshCompletion) {
        guard !isRefreshing else { return }

        isRefreshing = true

        let urlString = "\(baseURLString)/oauth2/token"

        let parameters: [String: Any] = [
            "access_token": accessToken,
            "refresh_token": refreshToken,
            "client_id": clientID,
            "grant_type": "refresh_token"
        ]

        sessionManager.request(urlString, withMethod: .post, parameters: parameters, encoding: .json).responseJSON { [weak self] response in
            guard let strongSelf = self else { return }

            if let json = response.result.value as? [String: String] {
                completion(true, json["access_token"], json["refresh_token"])
            } else {
                completion(false, nil, nil)
            }

            strongSelf.isRefreshing = false
        }
    }
}

let baseURLString = "https://some.domain-behind-oauth2.com"

let oauthHandler = OAuth2Handler(
    clientID: "12345678",
    baseURLString: baseURLString,
    accessToken: "abcd1234",
    refreshToken: "ef56789a"
)

let sessionManager = SessionManager()
sessionManager.adapter = oauthHandler
sessionManager.retrier = oauthHandler

let urlString = "\(baseURLString)/some/endpoint"

manager.request(urlString, withMethod: .get).validate().responseJSON { response in
    debugPrint(response)
}

Tests

I've added tests around the RequestAdapter to make sure all the SessionManager APIs actually call the adapter if set. I also added tests verifying the retrier is called when errors occur and that the adapter will be called again when the request is retried.

Internal Modifications

There are several internal changes that are worth calling out to make this work.

Validations

The first is that Validation is no longer run on the delegate's OperationQueue. In order to determine whether a Request encountered an error, we need to make sure we run all the validations first, otherwise the error won't be set and the retrier won't be called. This was a trivial change to implement, but is an important callout.

TaskConvertible

The TaskConvertible enum nested in the Request class allows us to store the un-adapted version of the Request before it is adapted and turned into a URLSessionTask. The retry method on the SessionManager uses the new originalTask property to extract the original urlRequest, adapt it if necessary, then create the new task and apply it to the Request. By setting a new task on the TaskDelegate, it automatically resets all the data tracked in the TaskDelegate as though it is a brand new task.

SessionDelegate

The SessionManager must be passed down to the SessionDelegate as a weak property in order to allow the SessionManager to retry the Request from the SessionDelegate. I originally had the retry logic in the Request class, but it became problematic when trying to figure out how to lock down the task creation on the SessionManager queue. While I don't love having to store the parent reference in the child, it's not so bad since it's not exposed publicly and it doesn't result in a retain cycle since it's a weak reference.

I'm very open to suggestions here on a better approach, but I think this is about the cleanest way to do it.

README Updates

I've added a Adapting and Retrying Requests section to the README to walk users through some of the ways you could use these protocols. I also created a fairly detailed OAuth2 example to demonstrate how to start to build a thread-safe refresh system that could be shared across multiple session managers.


Summary

Overall I think these are two very powerful protocols that will open up all sorts of possibilities for the Alamofire community. I'd love to see custom RequestRetrier implementations for linear and exponential back-off policies for things like background sync operations. Can't wait to see what everyone comes up with!

@cnoon
Copy link
Member Author

cnoon commented Sep 4, 2016

cc @jshier and @kcharwood. I'd love to get your guys thoughts here.

@cnoon cnoon force-pushed the feature/adapter-and-retrier-system branch from c9c1fdc to e225359 Compare September 4, 2016 16:39

#### RequestRetrier

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 a short example of how you could implement a refresh flow for OAuth2 access tokens.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like there should be a simpler example or walkthrough of the RequestRetrier protocol before jumping directly into an OAuth2 implementation. From looking at it I can't tell what's actually required for the protocol here and what's part of the OAuth2 flow.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jshier Quick Opinion, the name RequestAdapter makes me think on the Adapter pattern in GoF's Design Patterns. I understand why you pick the name, but I think the pattern you are using is more commonly referred to as the Interceptor pattern. So RequestInterceptor might be a better name.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree @jshier...a simpler example is definitely warranted. I'll make a note to try to get something put together when we rework all the docs. @tobiasoleary I respectfully disagree with the naming. An interceptor doesn't imply the Request will be modified in any way.

@jshier
Copy link
Contributor

jshier commented Sep 6, 2016

@cnoon Initial review complete. We discussed some of the general issues I had but it looks great otherwise!

@cnoon
Copy link
Member Author

cnoon commented Sep 6, 2016

Thanks for all the awesome comments here @jshier. Everything has been addressed at this point so I'm going to merge it in to keep the ball rollin'. 👍🏼

@cnoon cnoon merged commit 70067ba into swift3 Sep 6, 2016
@cnoon cnoon deleted the feature/adapter-and-retrier-system branch September 6, 2016 04:02
@alizainprasla
Copy link

@cnoon how to intercept response ? exactly like Adaptor for response.

@cnoon
Copy link
Member Author

cnoon commented May 12, 2018

You have a couple of places you could do that @alizainprasla, the validation closures or in the response closures. The validation closures give you a chance to inspect the response to decide whether to throw an error or not. If you do throw an error, then the retrier will be called to give you the option to retry the request if you so choose.

@ricardopereira
Copy link
Contributor

ricardopereira commented May 15, 2018

@cnoon Wouldn't be nice the RequestAdapter.adapt(urlRequest:) method also use a completion closure? For instance, if you know when the access token will expire then you could request a new token immediately instead of doing a request and waiting for a 401, sparing a request 😊.

typealias RequestAdaptCompletion = (_ urlRequest: URLRequest) -> Void

func adapt(_ urlRequest: URLRequest, completion: @escaping RequestAdaptCompletion) throws {
    if userTokenDetails.expires.timeIntervalSince(userTokenDetails.issued) <= 0 {
        reauthorize { newUserTokenDetails in
            urlRequest.setValue("Bearer " + newUserTokenDetails.accessToken, forHTTPHeaderField: "Authorization")
            completion(urlRequest)
        }
    }
    else {
        completion(urlRequest)
    }
}

Does it make sense? Should I do a PR?

@jshier
Copy link
Contributor

jshier commented May 15, 2018

@ricardopereira It would be much easier to just allow adapter errors to trigger retry behavior. I'm not sure if that would work now, but it might become possible for Alamofire 5. Such a feature likely wouldn't ship until then anyway.

@ricardopereira
Copy link
Contributor

@jshier That sounds even better, would be simpler to reuse the logic behind reauthentication. Should I open a Feature request?

@jshier
Copy link
Contributor

jshier commented May 15, 2018

Sure. At the very least it will give us a fresh issues to discuss adaptation vs. retry.

@cnoon
Copy link
Member Author

cnoon commented May 17, 2018

@ricardopereira and @jshier,

This can actually already be done on AF4.x. I know b/c we're doing it in our internal Nike networking library.

How?

We throw custom AdaptError types tailored to our use cases. For example, we have .missingCredential, .expiredCredential, and I believe one other case that we'll throw from adapt. Then in retry, we inspect the error to see if it was an AdaptError. If it was .expired, then we know we need to refresh. This allows us to avoid making the initial request with a credential we already know is expired.

@ricardopereira
Copy link
Contributor

@cnoon Thanks for sharing! I'll try doing the same.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants