Skip to content

Commit 6683b14

Browse files
committed
some updates
1 parent e57dbb9 commit 6683b14

File tree

1 file changed

+166
-33
lines changed

1 file changed

+166
-33
lines changed

Sources/SwiftAPIClient/Modifiers/RetryModifier.swift

Lines changed: 166 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,43 +3,82 @@ import Foundation
33
import FoundationNetworking
44
#endif
55
import HTTPTypes
6+
import Logging
7+
8+
public extension APIClient.Configs {
9+
10+
/// The condition used to determine whether a request should be retried.
11+
/// - Note: This configuration works only if you use the `retry` modifier.
12+
var retryCondition: RetryRequestCondition {
13+
get { self[\.retryCondition] ?? .default }
14+
set { self[\.retryCondition] = newValue }
15+
}
16+
17+
/// The maximum number of retries for a request. If `nil`, it will retry indefinitely.
18+
/// - Note: This configuration works only if you use the `retry` modifier.
19+
var retryLimit: Int? {
20+
get { self[\.retryLimit] }
21+
set { self[\.retryLimit] = newValue }
22+
}
23+
24+
/// The interval between retries. It can be a fixed time interval or a closure that takes the current retry count and returns a time interval.
25+
/// - Note: This configuration works only if you use the `retry` modifier.
26+
var retryInterval: (Int) -> TimeInterval {
27+
get { self[\.retryInterval] ?? { _ in 0 } }
28+
set { self[\.retryInterval] = newValue }
29+
}
30+
31+
/// The date formatter used to parse the `Retry-After` header when it contains a date. By default, it uses the RFC 1123 format.
32+
/// - Tips: `DateFormatter` creation is expensive, so if you need a custom format, create the formatter once and reuse it.
33+
var retryAfterHeaderDateFormatter: DateFormatter {
34+
get { self[\.retryAfterHeaderDateFormatter] ?? defaultRetryAfterHeaderDateFormatter }
35+
set { self[\.retryAfterHeaderDateFormatter] = newValue }
36+
}
37+
}
638

739
public extension APIClient {
840

9-
/// Retries the request if it fails.
10-
/// - Parameters:
11-
/// - limit: The maximum number of retries. If `nil`, it will retry indefinitely.
12-
/// - interval: The time interval to wait before the next retry. Defaults to 0 seconds.
13-
/// - condition: A closure that takes the request, the result of the request, and the client configs, and returns a Boolean indicating whether to retry the request.
14-
/// If not provided, it defaults to retrying safe methods (like GET) on error status codes or network errors.
15-
/// - retryKey: A closure that takes the request and returns a key used to group retries. Requests with the same key will share retry decisions.
16-
/// For example, if a rate limit error occurs for one request, all other requests with the same key (such as the same host) will be delayed. Defaults to `nil`, meaning no grouping.
17-
/// - Note: Like any modifier, this is order dependent. It takes in account only error from previous modifiers but not the following ones.
18-
func retry(
19-
retryKey: ((HTTPRequestComponents) -> AnyHashable)? = nil,
20-
when condition: RetryRequestCondition = .requestFailed,
21-
limit: Int?,
22-
interval: TimeInterval = 0
23-
) -> APIClient {
24-
retry(when: condition,limit: limit, interval: { _ in interval }, retryKey: retryKey)
41+
/// Sets the condition used to determine whether a request should be retried.
42+
/// - Parameter condition: A closure that takes the request, the result of the request,
43+
/// - Note: This configuration works only if you use the `retry` modifier.
44+
func retryCondition(_ condition: RetryRequestCondition) -> APIClient {
45+
configs(\.retryCondition, condition)
46+
}
47+
48+
/// Sets the maximum number of retries for a request. If `nil`, it will retry indefinitely.
49+
/// - Parameter limit: The maximum number of retries.
50+
/// - Note: This configuration works only if you use the `retry` modifier.
51+
func retryLimit(_ limit: Int?) -> APIClient {
52+
configs(\.retryLimit, limit)
2553
}
2654

55+
/// Sets the interval between retries. It can be a fixed time interval or a closure that takes the current retry count and returns a time interval.
56+
/// - Parameter interval: A closure that takes the current retry count (starting from 0
57+
/// - Note: This configuration works only if you use the `retry` modifier.
58+
func retryInterval(_ interval: @escaping (Int) -> TimeInterval) -> APIClient {
59+
configs(\.retryInterval, interval)
60+
}
61+
62+
/// Sets a fixed interval between retries.
63+
/// - Parameter interval: The time interval to wait before the next retry.
64+
/// - Note: This configuration works only if you use the `retry` modifier.
65+
func retryInterval(_ interval: TimeInterval) -> APIClient {
66+
retryInterval { _ in interval }
67+
}
68+
}
69+
70+
public extension APIClient {
71+
2772
/// Retries the request if it fails.
2873
/// - Parameters:
29-
/// - limit: The maximum number of retries. If `nil`, it will retry indefinitely.
30-
/// - interval: A closure that takes the current retry count (starting from 0) and returns the time interval to wait before the next retry. If not provided, it defaults to 0 seconds.
31-
/// - condition: A closure that takes the request, the result of the request, and the client configs, and returns a Boolean indicating whether to retry the request.
32-
/// If not provided, it defaults to retrying safe methods (like GET) on error status codes or network errors.
3374
/// - retryKey: A closure that takes the request and returns a key used to group retries. Requests with the same key will share retry decisions.
3475
/// For example, if a rate limit error occurs for one request, all other requests with the same key (such as the same host) will be delayed. Defaults to `nil`, meaning no grouping.
3576
/// - Note: Like any modifier, this is order dependent. It takes in account only error from previous modifiers but not the following ones.
77+
/// - Tip: You can customize the retry behavior by setting the `retryCondition`, `retryLimit`, and `retryInterval` configurations. Also, there is `\.retryAfterHeaderDateFormatter` configuration to customize parsing of `Retry-After` header.
3678
func retry(
37-
when condition: RetryRequestCondition = .requestFailed,
38-
limit: Int?,
39-
interval: @escaping (Int) -> TimeInterval,
4079
retryKey: ((HTTPRequestComponents) -> AnyHashable)? = nil
4180
) -> APIClient {
42-
httpClientMiddleware(RetryMiddleware(retryKey: retryKey, limit: limit, interval: interval, condition: condition))
81+
httpClientMiddleware(RetryMiddleware(retryKey: retryKey))
4382
}
4483
}
4584

@@ -80,6 +119,52 @@ public struct RetryRequestCondition {
80119
}
81120
}
82121

122+
/// Combines multiple `RetryRequestCondition` instances using a logical AND operation.
123+
/// The resulting condition will only return `true` if all conditions return `true`.
124+
/// - Parameter conditions: An array of `RetryRequestCondition` instances to combine.
125+
/// - Returns: A new `RetryRequestCondition` that represents the combined conditions.
126+
public static func and(_ conditions: RetryRequestCondition...) -> RetryRequestCondition {
127+
and(conditions)
128+
}
129+
130+
/// Combines multiple `RetryRequestCondition` instances using a logical AND operation.
131+
/// The resulting condition will only return `true` if all conditions return `true`.
132+
/// - Parameter conditions: An array of `RetryRequestCondition` instances to combine.
133+
/// - Returns: A new `RetryRequestCondition` that represents the combined conditions.
134+
public static func and(_ conditions: [RetryRequestCondition]) -> RetryRequestCondition {
135+
RetryRequestCondition { request, result, configs in
136+
for condition in conditions {
137+
if !condition.shouldRetry(request: request, result: result, configs: configs) {
138+
return false
139+
}
140+
}
141+
return true
142+
}
143+
}
144+
145+
/// Combines multiple `RetryRequestCondition` instances using a logical OR operation.
146+
/// The resulting condition will return `true` if any of the conditions return `true`.
147+
/// - Parameter conditions: An array of `RetryRequestCondition` instances to combine.
148+
/// - Returns: A new `RetryRequestCondition` that represents the combined conditions.
149+
public static func or(_ conditions: RetryRequestCondition...) -> RetryRequestCondition {
150+
or(conditions)
151+
}
152+
153+
/// Combines multiple `RetryRequestCondition` instances using a logical OR operation.
154+
/// The resulting condition will return `true` if any of the conditions return `true`.
155+
/// - Parameter conditions: An array of `RetryRequestCondition` instances to combine.
156+
/// - Returns: A new `RetryRequestCondition` that represents the combined conditions.
157+
public static func or(_ conditions: [RetryRequestCondition]) -> RetryRequestCondition {
158+
RetryRequestCondition { request, result, configs in
159+
for condition in conditions {
160+
if condition.shouldRetry(request: request, result: result, configs: configs) {
161+
return true
162+
}
163+
}
164+
return false
165+
}
166+
}
167+
83168
/// Combines two `RetryRequestCondition` instances using a logical OR operation.
84169
/// The resulting condition will return `true` if either condition returns `true`.
85170
/// - Parameter other: Another `RetryRequestCondition` to combine with.
@@ -90,21 +175,42 @@ public struct RetryRequestCondition {
90175
}
91176
}
92177

178+
/// The default `RetryRequestCondition` that retries safe HTTP methods (like GET) when the request fails due to error status codes or network errors.
179+
/// This condition combines the following:
180+
/// - `requestMethodIsSafe`: Ensures that only safe HTTP methods are retried.
181+
/// - `requestFailed`: Retries when the request fails due to network errors.
182+
/// - `retryableStatusCode`: Retries when the response status code indicates a transient failure.
183+
/// This default behavior is suitable for most scenarios where idempotent requests should be retried on failure.
184+
public static var `default`: RetryRequestCondition {
185+
.and(
186+
.requestMethodIsSafe,
187+
.or(
188+
.requestFailed,
189+
.retryableStatusCode
190+
)
191+
)
192+
}
193+
93194
/// A `RetryRequestCondition` that retries safe HTTP methods (like GET) when the request fails due to error status codes or network errors.
94195
public static let requestFailed = RetryRequestCondition { request, result, _ in
95-
guard request.method.isSafe else {
96-
return false
97-
}
98196
switch result {
99197
case let .success(response):
100-
return response.status.kind.isError
198+
return false
101199
case let .failure(error):
102200
return !(error is CancellationError)
103201
}
104202
}
203+
204+
/// A `RetryRequestCondition` that retries the request when the HTTP method is considered safe (e.g., GET, HEAD, OPTIONS).
205+
public static let requestMethodIsSafe = RetryRequestCondition { request, _, _ in
206+
request.method.isSafe
207+
}
208+
209+
/// A `RetryRequestCondition` that retries the request when the response status code indicates a failure that is typically transient.
210+
public static let retryableStatusCode = RetryRequestCondition.statusCodes([408, 421, 429, 500, 502, 503, 504, 509])
105211

106212
/// A `RetryRequestCondition` that retries the request when the response status code is `429 Too Many Requests`.
107-
public static let rateLimitExceeded = RetryRequestCondition.requestFailed.and(.statusCodes(.tooManyRequests))
213+
public static let rateLimitExceeded = RetryRequestCondition.statusCodes(.tooManyRequests)
108214

109215
/// A `RetryRequestCondition` that retries requests with defined HTTP methods.
110216
public static func methods(_ methods: HTTPRequest.Method...) -> RetryRequestCondition {
@@ -137,15 +243,15 @@ public struct RetryRequestCondition {
137243
private struct RetryMiddleware: HTTPClientMiddleware {
138244

139245
let retryKey: ((HTTPRequestComponents) -> AnyHashable)?
140-
let limit: Int?
141-
let interval: (Int) -> TimeInterval
142-
let condition: RetryRequestCondition
143246

144247
func execute<T>(
145248
request: HTTPRequestComponents,
146249
configs: APIClient.Configs,
147250
next: @escaping Next<T>
148251
) async throws -> (T, HTTPResponse) {
252+
let condition = configs.retryCondition
253+
let limit = configs.retryLimit
254+
let interval = configs.retryInterval
149255
if let retryKey {
150256
await waitForSynchronizedAccess(id: retryKey(request), of: Void.self)
151257
}
@@ -183,7 +289,9 @@ private struct RetryMiddleware: HTTPClientMiddleware {
183289
while true {
184290
do {
185291
let (data, response) = try await retry()
186-
response.headerFields[.retryAfter]
292+
retryAfterHeader = response.headerFields[.retryAfter].flatMap {
293+
decodeRetryAfterHeader($0, formatter: configs.retryAfterHeaderDateFormatter)
294+
} ?? 0
187295
if !needRetry(.success(response)) {
188296
return (data, response)
189297
}
@@ -197,4 +305,29 @@ private struct RetryMiddleware: HTTPClientMiddleware {
197305
}
198306
}
199307

308+
private func decodeRetryAfterHeader(_ value: String, formatter: DateFormatter) -> TimeInterval? {
309+
// seconds
310+
if let seconds = TimeInterval(value) {
311+
return seconds
312+
}
313+
314+
// RFC 1123 date
315+
if let date = formatter.date(from: value) {
316+
let delta = date.timeIntervalSinceNow
317+
return delta > 0 ? delta : 0
318+
}
319+
320+
Logger(label: "SwiftAPIClient").warning("Failed to parse Retry-After header: '\(value)' using '\(formatter.dateFormat ?? "nil")' format.")
321+
return nil
322+
}
323+
324+
private let defaultRetryAfterHeaderDateFormatter: DateFormatter = {
325+
let formatter = DateFormatter()
326+
// RFC 1123
327+
formatter.locale = Locale(identifier: "en_US_POSIX")
328+
formatter.timeZone = TimeZone(secondsFromGMT: 0)
329+
formatter.dateFormat = "EEE',' dd MMM yyyy HH:mm:ss zzz"
330+
return formatter
331+
}()
332+
200333
private struct ImpossibleError: Error {}

0 commit comments

Comments
 (0)