When your app is complex and you need to manage different http webservices, or you want to avoid using the shared client to create a decupled implementation, creating a custom HTTPClient
is the best thing you can do.
With a custom HTTPClient
you can define your own rules to validate and handle a response coming from a particular webservice, as well as any edge cases you may encounter.
This is a custom client:
public lazy var b2cClient: HTTPClient = {
var config = URLSessionConfiguration.default
config.httpShouldSetCookies = true
config.networkServiceType = .responsiveData
let client = HTTPClient(baseURL: "https://myappb2c.ws.org/api/v2/",configuration: config)
// Setup some common HTTP Headers for all requests
client.headers = HTTPHeaders([
.init(name: .userAgent, value: myAgent),
.init(name: "X-API-Experimental", value: "true")
])
return client
}()
It contains a particular URLSessionConfiguration
and a set of common HTTP Headers which are automatically used by any HTTPRequest
you run on it.
Each raw response from a network call can be validated by a set of objects that conform to the HTTPValidator
protocol.
HTTPClient
instances have a validators
array which may contain an ordered list of validators which respond to this method:
func validate(response: HTTPResponse, forRequest request: HTTPRequest) -> HTTPResponseValidatorResult { }
This function analyzes the response coming from request and determines the next step.
In particular, you can return:
nextValidator
The response is okay, you can move to the next validator (if any) or return the response received by the server.
The HTTPResponse
is a class where the methods are open, so you can alter these values inside the validators if you need.
nextValidatorWithResponse
The response is okay, you can move to the next validator (if any) or return. In this case you can return a new HTTPResponse
subclass with additional properties.
This is the case where you must do some additional business logic with your response before sending it outside the library.
failChain(Error)
Received response is not valid (for example you have an error
node in your json response which indicates the failure). You can parse the response and return a custom error bypassing the initial response.
retry(HTTPRetryStrategy)
Something bad has occurred; however, you can retry if maxRetries
of the HTTPRequest
is > 1.
The options are:
immediate
will retry the original call immediatelydelayed
will retry the original call after a given amount of secondsexponential
andfibonacci
are the same asdelayed
, but with sequentially increasing delay timesafter(HTTPRequest, TimeInterval, AltRequestCatcher?)
will retry the original call after calling an alternate request. For example, if you are making an authenticated request and the session has expired; you can then call a login alternate request to perform a new login and retry the original call.afterTask(TimeInterval, RetryTask, RetryTaskErrorCatcher?)
performs an async task before retrying the original request. By using an asyncTask
, you may perform work outside of the scope of RealHTTP and inject whatever you need into the original request. Note that, like with the existing retry withHTTPRequest
strategy, any error triggered by the asyncTask
is not propagated to the original request. However, a callback could be provided to at least "see" it.
We'll take a closer look at these strategies below.
Each new client implements a single validators
object called HTTPDefaultValidator
.
This object contains the standard logic to validate a response from the server.
In particular, it:
- Checks for empty responses. If you set
allowsEmptyResponses = false
when an empty response is received, the chain will fail with anHTTPError(.emptyResponse)
error. - Checks the HTTP status code. If the code is an error code, the chain may fail (see the check above)
- If HTTP status code is an error code or the underlying
URLSession
received an error code (timeout, connection drop etc.) theretriableHTTPStatusCodes
map is read. If the error is in that list, then a new retry may be triggered (but only ifmaxRetries
of the originalHTTPRequest
> 0).
This validator should never be removed unless you have a really unusual use case for parsing and validating errors.
Typically, you will want to add a new validator after this, in order to perform your own logic for handling the unique setup of your webservice.
RealHTTP also provides a special validator called HTTPAltRequestValidator
. This validator can be used when you need to execute a specific HTTPRequest
if another request fails for a given reason.
A typical example would be the silent login operation; if you receive an unauthorized
or .forbidden
error for a protected resource you may want to try a silent login operation, then re-execute the initial (failed) request.
The HTTPAltRequestValidator
is triggered by certain HTTP status codes; by default 401/403
require a callback which returns a specific HTTPRequest
for a certain failed request.
NOTE: By default this validator is not triggered by network failure. If you want to perform it even when no response is received from server, add the
HTTPStatusCode
.none
to the list in thestatusCodes
property.
Often, you will want to execute your validator first (before the default one).
This is an example which performs a JWT session token refresh before retrying the initial request:
let client = HTTPClient(...)
// The alt validator is triggered only when 401 error is received from any request's response.
let authValidator = HTTPAltRequestValidator(statusCodes: [.unauthorized], { request, response in
// If triggered here you'll specify the alt call to execute in order to refresh a JWT session token
// before any retry of the initial failed request.
return HTTPRequest("https://.../refreshToken")
} onReceiveAltResponse: { request, response in
// Once you have received response from your `refreshToken` call
// you can do anything you need to use it.
// In this example we'll set the global client's authorization header.
let receivedToken = response.data...
client.headers.set(.authorization, receivedToken)
}
// append at the top of the validators chain
client.validators.insert(authValidator, at: 0)
When your client has custom logic to run before returning responses you can create your own validator to ensure all your requests are managed by the same validation logic.
Consider a webservice which always returns a JSON object with the following keys:
code
:0
if everything is okay,1
if an error has occurrederrorMsg
: the message error, if notnull
something bad occurreddata
: a dictionary with the response of the request, must be always present
We can create a custom validator for this logic as seen below:
import SwiftyJSON
public class MyBadWSValidator: HTTPValidator {
public func validate(response: HTTPResponse, forRequest request: HTTPRequest) -> HTTPResponseValidatorResult {
// Structure logic check
guard let data = response.data, let jsonData = JSON(data) else {
return .failChain(HTTPError(.invalidResponse)) // response must be always JSON, no retry is allowed
}
guard data["code"].intValue == 0 else {
let errorMsg = data["errorMsg"].string ?? "Unknown error"
return .failChain(HTTPError(.internal, errorMsg)) // an error has occurred
}
// Business logic check
let isRetriable = data["retriable"].boolValue
guard data["data"].notExist == true, data["data"].type != JSON.Type.dictionary else {
if isRetriable {
return .retry(.fibonacci)
}
return .failChain(HTTPError(.invalidResponse)) // response must be always JSON, no retry is allowed
}
return .nextValidator // everything is okay
}
}
To add this validator to your client next to the default one just append it to the validators
property:
// Configure client
let client = HTTPClient(...)
client.validators.append(MyBadWSValidator())
// Prepare a request
let req = HTTPRequest(...)
req.maxRetries = 3
let result = try await req.fetch(client)
Once you set it all the requests executed in this client will also be validated by your own validator.
The retry strategy called .after(HTTPRequest, TimeInterval, AltRequestCatcher?)
allows you to execute an alternate request if the initial one fails, and then retry the initial one again.
This kind of retry is particularly useful for silent login when an authenticated request fails due to an expired session.
Consider this auth call:
let usersBooks = HTTPRequest("https://.../user/books/scifi")
userBooks.headers = HTTPHeaders([
"X-Token": authToken
])
let books = try await usersBooks.fetch()
What happens if token is expired? Your call fails with a poor user experience.
You can make it a better experience by attempting to refresh the token and automatically retry your call.
How?
First, create your own custom validator:
import SwiftyJSON
public class SilentLoginValidator: HTTPValidator {
/// This is the request which is used to refresh the token
public var tokenRefreshRequest: HTTPRequest
public func validate(response: HTTPResponse, forRequest request: HTTPRequest) -> HTTPResponseValidatorResult {
guard response.statusCode == .unauthorized else {
// If unauthorized error has occurred we'll try to make a silent login and
// retry the initial request after 0.3 seconds by setting the authorization token,
let silentLogin: HTTPRetryStrategy = .after(tokenRefreshRequest, 0.3) { request, response in
if let response = JSONSerialization.jsonObject(with: response.data ?? Data(), options: .fragmentsAllowed) as? [String: String] {
// Set the new received token
request.headers.set("X-Token", response["token"] as! String)
}
}
return .retry(silentLogin)
}
// No error, move to the next validator
return .nextValidator
}
}
Just set this validator to automatically retry after performing a silent login.
This strategy performs an async task before retrying the original request.
With the after()
strategy, you must provide another HTTPRequest
to be performed before the retry.
If the request Authorization should be handled by something other than an HTTP service, there is no way to interface with the Validator to create/refresh the authorization before the retry.
However, by using afterTask()
, you may perform work outside of the scope of RealHTTP and inject whatever the user wants into the original request.
Note that, like with the existing retry with HTTPRequest
strategy, any error triggered by the async Task
is not propagated to the original request. However, a callback could be provided to at least "see" it.
// Define an async function called before retry the original request.
let patchRequestAndRetryTask: (HTTPRequest) async throws -> Void = { originalRequest in
originalRequest.headers.set(.authBearerToken("abcdefg"))
}
// Create a custom client with a callback validator to show up the action
let newClient = HTTPClient(baseURL: nil)
newClient.validators = [
CallbackValidator { response, request in
if request.currentRetry < 2 {
return .retry(.afterTask(4, { originalRequest in
// we can specify an async function which modify the original request
// before retry it, after 4 seconds.
try await patchRequestAndRetryTask(originalRequest)
}, { error in
retryTaskError = error
}))
} else {
// just one retry, if it fails an error is triggered
return .failChain(HTTPError(.tooManyRequests))
}
}
]
// Execute any request
let aRequest = HTTPRequest { // configure your request }
let aResponse = try await aRequest.fetch(newClient)