Retriers
Retriers are a very useful tool for repeating requests that have failed. In Squid, they can be used in a variety of contexts. Some examples are the following:
- If an error occurs due to a missing internet connection or rate limiting, the request can be retried multiple times with exponential backoff.
- If an error occurs due to an expired token and results in a 401 response, the token can be refreshed and the request is retried with the new token.
How to Use Retriers
Retriers are defined at the API level - the justification being that e.g. authorization is implemented equally for all calls against an API.
In order to use a retrier, the HttpService
provides a HttpService.retrierFactory
property. Note that a new retrier is created per request to enable stateful retriers (e.g. exponential backoff). Creating a factory from a retrier is, however, very straightforward as you will see in the following.
The retrier itself needs to conform to the Retrier
protocol which defines a single Retrier.retry(_:failingWith:)
method that possibly retries a failed request.
To showcase how to use retriers in practice, we will consider the BackoffRetrier
that Squid provides out-of-the-box as well as implement our own retrier for Authorization.
Exponential Backoff
As applying an exponential backoff to a failed request can be standardized, i.e. is not dependent on a particular API, it is included in Squid directly.
As pointed out above, retriers need to be included into the API, so we modify our service from the first guide as follows:
struct MyApi: HttpService {
var apiUrl: UrlConvertible {
"jsonplaceholder.typicode.com"
}
var retrierFactory: RetrierFactory {
BackoffRetrier.factory()
}
}
As a result, all requests that are now scheduled against the API will be retried when particular errors occur (e.g. “no connection”, 429 status code, …, see BackoffRetrier.defaultRetryCondition(_:)
).
In fact, you do not need to change anything else for this to work. You can schedule requests just as before.
Refreshing Tokens
Refreshing tokens for accessing protected resources is probably one of the most common use cases for retriers when targeting secure APIs. However, Squid does not include this feature out-of-the-box since the particular implementation is highly dependent on a particular API.
In the following, we will walk you through the process of setting up services and requests that enable retrying requests for authentication. Throughout, we assume the following:
- We have two API endpoints: (A) one for authentication which can be accessed easily and (B) one for accessing protected resources where we need to authenticate.
- Authentication is performed via an access token that is passed via the HTTP
Authorization
header. Once received, this token is, however, valid for a short period of time only (e.g. 10 minutes). - To request a new access token without passing user credentials (e.g. username/password), we have a refresh token that does not expire.
- When we try to access a resource from endpoint B with an expired access token, we get a response status code of 401.
- We can then receive a new access (and refresh) token by sending a request to endpoint A.
Defining the Authentication Service
At first, we want to define the authentication service. Usually, you will need to send a login request initially to obtain an access and a refresh token. However, to simplify this guide, we assume that we have these tokens already stored in our keychain.
To interact with the keychain, we use some (thread-safe and caching) instance that conforms to the following protocol (note that this protocol does not handle errors - again, to simplify this guide):
protocol KeychainService {
func store<K>(_ value: K, for key: String) where K: Encodable
func load<K>(_ type: K.Type, for key: String) -> K where K: Decodable
}
Given that protocol, we can define our authentication service:
struct MyAuthApi {
private let keychain: KeychainService
init(keychain: KeychainService) {
self.keychain = keychain
}
var accessToken: String {
get {
keychain.load(String.self, for: "access_token")
} set {
keychain.store(newValue, for: "access_token")
}
}
var refreshToken: String {
get {
keychain.load(String.self, for: "refresh_token")
} set {
keychain.store(newValue, for: "refresh_token")
}
}
}
extension MyAuthApi: HttpService {
var apiUrl: String {
"auth.borchero.com"
}
}
Note that this definition of a service is very similar to before - we only added a few properties that are associated with the authentication service.
Defining a Protected API
Using our authentication service, we can define the API which needs to provide access tokens to access protected resources.
All requests scheduled against this API need to provide a valid HTTP Authorization
header, therefore, our API definition can set this automatically:
struct MyProtectedApi {
private let auth: MyAuthApi
init(auth: MyAuthApi) {
self.auth = auth
}
}
extension MyProtectedApi: HttpService {
var apiUrl: String {
"squid.borchero.com"
}
var header: HttpHeader {
[.authorization: "Bearer \(auth.accessToken)"]
}
}
Defining the Retrier
Finally, we want to retry requests scheduled against MyProtectedApi
whenever we receive a 401 HTTP status code as response. Before retrying, however, we need to schedule a request against MyAuthApi
to refresh our token.
Therefore, we first want to define our request for refreshing tokens:
struct TokenRequestResponse: Decodable {
let accessToken: String
let refreshToken: String
}
struct TokenRequest: JsonRequest {
typealias Result = TokenRequestResponse
let refreshToken: String
var method: HttpMethod {
.post
}
var routes: HttpRoute {
["token"]
}
var body: HttpBody {
HttpData.Json(["refresh_token": refreshToken])
}
}
Here, we send the refresh token as JSON object to the server (usually, you would include some more information here) and we expect to receive an access as well as a refresh token.
At this point, it is possible to define our retrier (note that you will need to import Combine
for this):
class AuthorizationRetrier: Retrier {
private let auth: MyAuthApi
private var cancellable: Cancellable?
init(auth: MyAuthApi) {
self.auth = auth
}
func retry<R>(_ request: R, failingWith error: Squid.Error) -> Future<Bool, Never> where R: Request {
return Future { promise in
switch error {
case .requestFailed(statusCode: 401, response: _):
// Here, we want to request a new token.
let request = TokenRequest(refreshToken: self.auth.refreshToken)
// Note that we do not need any synchronization primitives here as this retrier is used by a *single request*
self.cancellable = request.schedule(with: self.auth).sink(receiveCompletion: { completion in
switch completion {
case .finished:
// We don't need to do anything
break
case .failure(_):
// The request failed, we don't need to retry the original request
promise(.success(false))
}
}) { value in
self.auth.accessToken = value.accessToken
self.auth.refreshToken = value.refreshToken
// The request finished successfully, retry the original request
promise(.success(true))
}
default:
// Some other error occurred, we do not want to retry the request
promise(.success(false))
}
}
}
}
Attaching the Retrier to the API
Now that we defined our retrier, we need to attach it to MyProtectedApi
to actually use it for requests scheduled against it. We can easily do this as follows:
extension MyProtectedApi {
var retrierFactory: RetrierFactory {
return AnyRetrierFactory {
return AuthorizationRetrier(auth: self.auth)
}
}
}
We need to provide a factory function here to ensure that a new instance of the retrier is used for each request.
Final Notes
You might think that defining this retrier was a lot of work. However, you probably realized that retriers can be very powerful. Further, all requests that you schedule against MyProtectedApi
can be scheduled equally well against an API that is not protected. That means that you can switch between APIs (e.g. for staging and production) hardly changing any of your code.