Skip to content

Commit d384ad1

Browse files
authored
feat: add flexible request retry strategy (#34)
* feat: add flexible request retry strategy * tests: add unit tests
1 parent caa6a26 commit d384ad1

File tree

10 files changed

+532
-110
lines changed

10 files changed

+532
-110
lines changed

Package@swift-5.10.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ let package = Package(
1818
],
1919
dependencies: [
2020
.package(url: "https://github.com/space-code/atomic", exact: "1.1.0"),
21-
.package(url: "https://github.com/space-code/typhoon", exact: "1.2.1"),
21+
.package(url: "https://github.com/space-code/typhoon", exact: "1.4.0"),
2222
.package(url: "https://github.com/WeTransfer/Mocker", exact: "3.0.1"),
2323
],
2424
targets: [

Package@swift-6.0.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ let package = Package(
1818
],
1919
dependencies: [
2020
.package(url: "https://github.com/space-code/atomic", exact: "1.1.0"),
21-
.package(url: "https://github.com/space-code/typhoon", exact: "1.2.1"),
21+
.package(url: "https://github.com/space-code/typhoon", exact: "1.4.0"),
2222
.package(url: "https://github.com/WeTransfer/Mocker", exact: "3.0.1"),
2323
],
2424
targets: [

Sources/NetworkLayer/Classes/Core/Services/RequestProcessor/RequestProcessor.swift

Lines changed: 123 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -9,128 +9,159 @@ import Typhoon
99

1010
// MARK: - RequestProcessor
1111

12-
/// An object that handles request processing.
12+
/// An actor responsible for executing network requests in a thread-safe manner.
13+
///
14+
/// `RequestProcessor` manages the entire lifecycle of a request, including construction,
15+
/// authentication adaptation, execution, credential refreshing, and retry logic.
1316
actor RequestProcessor {
14-
// MARK: Properties
17+
// MARK: - Properties
1518

16-
/// The network layer's configuration.
19+
/// The network layer's configuration containing session settings and decoders.
1720
private let configuration: Configuration
18-
/// The object that coordinates a group of related, network data transfer tasks.
21+
22+
/// The underlying `URLSession` used to manage data transfer tasks.
1923
private let session: URLSession
20-
/// The data request handler.
24+
25+
/// The handler responsible for managing the state and events of a specific data task.
2126
private let dataRequestHandler: any IDataRequestHandler
22-
/// The request builder.
27+
28+
/// The component used to transform `IRequest` models into `URLRequest` objects.
2329
private let requestBuilder: IRequestBuilder
24-
/// The retry policy service.
25-
private let retryPolicyService: IRetryPolicyService
26-
/// The authenticator interceptor.
30+
31+
/// An optional service that handles request retries based on specific strategies.
32+
private let retryPolicyService: IRetryPolicyService?
33+
34+
/// An optional interceptor for modifying requests and handling authentication challenges.
2735
private let interceptor: IAuthenticationInterceptor?
28-
/// The delegate.
36+
37+
/// A thread-safe delegate for observing and validating request processor events.
2938
private var delegate: SafeRequestProcessorDelegate?
3039

31-
// MARK: Initialization
40+
/// A global evaluator to determine if a retry should be attempted based on the error.
41+
/// This applies to all requests processed by this instance.
42+
private let retryEvaluator: (@Sendable (Error) -> Bool)?
43+
44+
// MARK: - Initialization
3245

3346
/// Creates a new `RequestProcessor` instance.
3447
///
3548
/// - Parameters:
36-
/// - configure: The network layer's configuration.
49+
/// - configuration: The network layer's configuration.
3750
/// - requestBuilder: The request builder.
3851
/// - dataRequestHandler: The data request handler.
3952
/// - retryPolicyService: The retry policy service.
53+
/// - delegate: A thread-safe delegate for processor events.
54+
/// - interceptor: An authenticator interceptor.
4055
init(
4156
configuration: Configuration,
4257
requestBuilder: IRequestBuilder,
4358
dataRequestHandler: any IDataRequestHandler,
44-
retryPolicyService: IRetryPolicyService,
59+
retryPolicyService: IRetryPolicyService?,
4560
delegate: SafeRequestProcessorDelegate?,
46-
interceptor: IAuthenticationInterceptor?
61+
interceptor: IAuthenticationInterceptor?,
62+
retryEvaluator: (@Sendable (Error) -> Bool)?
4763
) {
4864
self.configuration = configuration
4965
self.requestBuilder = requestBuilder
5066
self.dataRequestHandler = dataRequestHandler
5167
self.retryPolicyService = retryPolicyService
5268
self.delegate = delegate
5369
self.interceptor = interceptor
70+
self.retryEvaluator = retryEvaluator
71+
5472
self.dataRequestHandler.urlSessionDelegate = configuration.sessionDelegate
73+
5574
session = URLSession(
5675
configuration: configuration.sessionConfiguration,
5776
delegate: dataRequestHandler,
5877
delegateQueue: configuration.sessionDelegateQueue
5978
)
6079
}
6180

62-
// MARK: Private
81+
// MARK: - Private Methods
6382

64-
/// Performs a network request.
83+
// swiftlint:disable function_body_length
84+
/// Orchestrates the execution of a network request, including building, adaptation, and error handling.
6585
///
6686
/// - Parameters:
67-
/// - request: The network request.
68-
/// - strategy: The retry policy strategy.
69-
/// - delegate: A protocol that defines methods that URL session instances call on their delegates
70-
/// to handle session-level events, like session life cycle changes.
71-
/// - configure: A closure to configure the URLRequest.
72-
///
73-
/// - Returns: The response from the network request.
87+
/// - request: The network request model.
88+
/// - strategy: An optional override for the retry policy strategy.
89+
/// - delegate: A delegate to handle session-level events.
90+
/// - configure: A closure for final modifications to the `URLRequest`.
91+
/// - Returns: A `Response` object containing the raw `Data`.
7492
private func performRequest(
7593
_ request: some IRequest,
7694
strategy: RetryPolicyStrategy? = nil,
7795
delegate: URLSessionDelegate?,
78-
configure: (@Sendable (inout URLRequest) throws -> Void)?
96+
configure: (@Sendable (inout URLRequest) throws -> Void)?,
97+
shouldRetry: (@Sendable (Error) -> Bool)?
7998
) async throws -> Response<Data> {
80-
try await performRequest(strategy: strategy) { [weak self] in
81-
guard let self, var urlRequest = try requestBuilder.build(request, configure) else {
82-
throw NetworkLayerError.badURL
83-
}
99+
try await performRequest(
100+
strategy: strategy,
101+
send: { [weak self] in
102+
guard let self else { throw NetworkLayerError.badURL }
84103

85-
try await adapt(request, urlRequest: &urlRequest, session: session)
104+
var urlRequest = try requestBuilder.build(request, configure) ?? { throw NetworkLayerError.badURL }()
86105

87-
try await self.delegate?.wrappedValue?.requestProcessor(self, willSendRequest: urlRequest)
106+
try await adapt(request, urlRequest: &urlRequest, session: session)
88107

89-
let task = session.dataTask(with: urlRequest)
108+
try await self.delegate?.wrappedValue?.requestProcessor(self, willSendRequest: urlRequest)
90109

91-
do {
92-
let response = try await dataRequestHandler.startDataTask(task, delegate: delegate)
110+
let task = session.dataTask(with: urlRequest)
93111

94-
if request.requiresAuthentication {
95-
let isRefreshedCredential = try await refresh(
96-
urlRequest: urlRequest,
97-
response: response,
98-
session: session
99-
)
112+
do {
113+
let response = try await dataRequestHandler.startDataTask(task, delegate: delegate)
100114

101-
if isRefreshedCredential {
102-
throw AuthenticatorInterceptorError.missingCredential
115+
if request.requiresAuthentication {
116+
let isRefreshedCredential = try await refresh(
117+
urlRequest: urlRequest,
118+
response: response,
119+
session: session
120+
)
121+
122+
if isRefreshedCredential {
123+
throw AuthenticatorInterceptorError.missingCredential
124+
}
103125
}
126+
127+
try await validate(response)
128+
129+
return response
130+
} catch {
131+
throw error
104132
}
133+
}, shouldRetry: { [weak self] error in
134+
guard let self else { return false }
105135

106-
try await validate(response)
136+
let globalResult = retryEvaluator?(error) ?? true
107137

108-
return response
109-
} catch {
110-
throw error
138+
let localResult = shouldRetry?(error) ?? true
139+
140+
return globalResult && localResult
111141
}
112-
}
142+
)
113143
}
114144

115-
/// Adapts an initial request.
145+
// swiftlint:enable function_body_length
146+
147+
/// Modifies the `URLRequest` to include authentication credentials if required.
116148
///
117149
/// - Parameters:
118-
/// - request: The request model.
119-
/// - urlRequest: The request that needs to be authenticated.
120-
/// - session: The URLSession for which the request is being refreshed.
150+
/// - request: The initial request model.
151+
/// - urlRequest: The `URLRequest` being prepared for transport.
152+
/// - session: The current `URLSession`.
121153
private func adapt(_ request: some IRequest, urlRequest: inout URLRequest, session: URLSession) async throws {
122154
guard request.requiresAuthentication else { return }
123155
try await interceptor?.adapt(request: &urlRequest, for: session)
124156
}
125157

126-
/// Refreshes credential.
158+
/// Checks if a request requires a credential refresh and performs it if necessary.
127159
///
128160
/// - Parameters:
129-
/// - urlRequest: The request that needs to be authenticated.
130-
/// - response: The metadata associated with the response to an HTTP protocol URL load request.
131-
/// - session: The URLSession for which the request is being refreshed.
132-
///
133-
/// - Returns: `true` if the request's token is refreshed, false otherwise.
161+
/// - urlRequest: The failed or unauthorized request.
162+
/// - response: The received network response.
163+
/// - session: The current `URLSession`.
164+
/// - Returns: `true` if a refresh was triggered, `false` otherwise.
134165
private func refresh(
135166
urlRequest: URLRequest,
136167
response: Response<some Any>,
@@ -146,24 +177,28 @@ actor RequestProcessor {
146177
return false
147178
}
148179

149-
/// Performs a request with a retry policy.
180+
/// Wraps a request operation with retry logic provided by the `retryPolicyService`.
150181
///
151182
/// - Parameters:
152-
/// - strategy: The strategy for retrying the request.
153-
/// - send: The closure that sends the request.
154-
///
155-
/// - Returns: The response from the network request.
183+
/// - strategy: The strategy to apply for retries.
184+
/// - send: An asynchronous closure that executes the request logic.
185+
/// - shouldRetry: A closure to decide if a retry should occur based on the error.
186+
/// - Returns: The result of the request if successful.
156187
private func performRequest<T: Sendable>(
157188
strategy: RetryPolicyStrategy? = nil,
158-
_ send: @Sendable () async throws -> T
189+
send: @Sendable () async throws -> T,
190+
shouldRetry: @Sendable @escaping (Error) -> Bool
159191
) async throws -> T {
160-
do {
161-
return try await send()
162-
} catch {
163-
return try await retryPolicyService.retry(strategy: strategy, send)
192+
if let retryPolicyService {
193+
try await retryPolicyService.retry(strategy: strategy, onFailure: shouldRetry, send)
194+
} else {
195+
try await send()
164196
}
165197
}
166198

199+
/// Triggers the delegate's validation logic for the received HTTP response.
200+
///
201+
/// - Parameter response: The response object to validate.
167202
private func validate(_ response: Response<Data>) throws {
168203
guard let urlResponse = response.response as? HTTPURLResponse else { return }
169204
try delegate?.wrappedValue?.requestProcessor(
@@ -178,13 +213,32 @@ actor RequestProcessor {
178213
// MARK: IRequestProcessor
179214

180215
extension RequestProcessor: IRequestProcessor {
216+
/// Sends a network request and decodes the response into a specified type.
217+
///
218+
/// - Parameters:
219+
/// - request: The request model.
220+
/// - strategy: Optional retry strategy override.
221+
/// - delegate: Optional session delegate.
222+
/// - configure: Optional closure to modify the `URLRequest`.
223+
/// - shouldRetry: Optional closure to handle specific error filtering.
224+
/// - Returns: A `Response` object containing the decoded model of type `M`.
181225
func send<M: Decodable & Sendable>(
182226
_ request: some IRequest,
183227
strategy: RetryPolicyStrategy? = nil,
184228
delegate: URLSessionDelegate? = nil,
185-
configure: (@Sendable (inout URLRequest) throws -> Void)? = nil
229+
configure: (@Sendable (inout URLRequest) throws -> Void)? = nil,
230+
shouldRetry: (@Sendable (Error) -> Bool)? = nil
186231
) async throws -> Response<M> {
187-
let response = try await performRequest(request, strategy: strategy, delegate: delegate, configure: configure)
188-
return try response.map { data in try self.configuration.jsonDecoder.decode(M.self, from: data) }
232+
let response = try await performRequest(
233+
request,
234+
strategy: strategy,
235+
delegate: delegate,
236+
configure: configure,
237+
shouldRetry: shouldRetry
238+
)
239+
240+
return try response.map { data in
241+
try self.configuration.jsonDecoder.decode(M.self, from: data)
242+
}
189243
}
190244
}

0 commit comments

Comments
 (0)