Skip to content

Commit fb42e6e

Browse files
committed
some upgrades
1 parent 6683b14 commit fb42e6e

File tree

1 file changed

+113
-25
lines changed

1 file changed

+113
-25
lines changed

Sources/SwiftAPIClient/Modifiers/RetryModifier.swift

Lines changed: 113 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,72 @@ import FoundationNetworking
55
import HTTPTypes
66
import Logging
77

8+
public extension APIClient {
9+
10+
/// Retries the request if it fails.
11+
@available(*, deprecated, message: "Use `retry()` modifier with `retryLimit(_:)` configuration instead.")
12+
func retry(limit: Int?) -> APIClient {
13+
httpClientMiddleware(RetryMiddleware(limit: limit))
14+
}
15+
}
16+
17+
private struct RetryMiddleware: HTTPClientMiddleware {
18+
19+
let limit: Int?
20+
21+
func execute<T>(
22+
request: HTTPRequestComponents,
23+
configs: APIClient.Configs,
24+
next: @escaping Next<T>
25+
) async throws -> (T, HTTPResponse) {
26+
var count = 0
27+
func needRetry() -> Bool {
28+
if let limit {
29+
return count <= limit
30+
}
31+
return true
32+
}
33+
34+
func retry() async throws -> (T, HTTPResponse) {
35+
count += 1
36+
return try await next(request, configs)
37+
}
38+
39+
let response: HTTPResponse
40+
let data: T
41+
do {
42+
(data, response) = try await retry()
43+
} catch {
44+
if needRetry() {
45+
return try await retry()
46+
}
47+
throw error
48+
}
49+
if response.status.kind.isError, needRetry() {
50+
return try await retry()
51+
}
52+
return (data, response)
53+
}
54+
}
55+
856
public extension APIClient.Configs {
957

1058
/// The condition used to determine whether a request should be retried.
11-
/// - Note: This configuration works only if you use the `retry` modifier.
59+
/// - Note: This configuration works only if you use the `retry()` modifier.
1260
var retryCondition: RetryRequestCondition {
1361
get { self[\.retryCondition] ?? .default }
1462
set { self[\.retryCondition] = newValue }
1563
}
1664

1765
/// 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.
66+
/// - Note: This configuration works only if you use the `retry()` modifier.
1967
var retryLimit: Int? {
2068
get { self[\.retryLimit] }
2169
set { self[\.retryLimit] = newValue }
2270
}
2371

2472
/// 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.
73+
/// - Note: This configuration works only if you use the `retry()` modifier.
2674
var retryInterval: (Int) -> TimeInterval {
2775
get { self[\.retryInterval] ?? { _ in 0 } }
2876
set { self[\.retryInterval] = newValue }
@@ -34,51 +82,65 @@ public extension APIClient.Configs {
3482
get { self[\.retryAfterHeaderDateFormatter] ?? defaultRetryAfterHeaderDateFormatter }
3583
set { self[\.retryAfterHeaderDateFormatter] = newValue }
3684
}
85+
86+
/// The backoff policy used to determine how to handle global backoff scenarios, such as rate limiting.
87+
/// - Note: This configuration works only if you use the `retry()` modifier.
88+
var retryBackoffPolicy: RetryBackoffPolicy {
89+
get { self[\.retryBackoffPolicy] ?? .default }
90+
set { self[\.retryBackoffPolicy] = newValue }
91+
}
3792
}
3893

3994
public extension APIClient {
4095

4196
/// Sets the condition used to determine whether a request should be retried.
4297
/// - 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.
98+
/// - Note: This configuration works only if you use the `retry()` modifier.
4499
func retryCondition(_ condition: RetryRequestCondition) -> APIClient {
45100
configs(\.retryCondition, condition)
46101
}
47102

48103
/// Sets the maximum number of retries for a request. If `nil`, it will retry indefinitely.
49104
/// - Parameter limit: The maximum number of retries.
50-
/// - Note: This configuration works only if you use the `retry` modifier.
105+
/// - Note: This configuration works only if you use the `retry()` modifier.
51106
func retryLimit(_ limit: Int?) -> APIClient {
52107
configs(\.retryLimit, limit)
53108
}
54109

55110
/// 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.
56111
/// - 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.
112+
/// - Note: This configuration works only if you use the `retry()` modifier.
58113
func retryInterval(_ interval: @escaping (Int) -> TimeInterval) -> APIClient {
59114
configs(\.retryInterval, interval)
60115
}
61116

62117
/// Sets a fixed interval between retries.
63118
/// - Parameter interval: The time interval to wait before the next retry.
64-
/// - Note: This configuration works only if you use the `retry` modifier.
119+
/// - Note: This configuration works only if you use the `retry()` modifier.
65120
func retryInterval(_ interval: TimeInterval) -> APIClient {
66121
retryInterval { _ in interval }
67122
}
123+
124+
/// Sets the date formatter used to parse the `Retry-After` header when it contains a date. By default, it uses the RFC 1123 format.
125+
/// - Parameter formatter: The date formatter to use for parsing the `Retry-After`
126+
func retryAfterHeaderDateFormatter(_ formatter: DateFormatter) -> APIClient {
127+
configs(\.retryAfterHeaderDateFormatter, formatter)
128+
}
129+
130+
/// Sets the backoff policy used to determine how to handle global backoff scenarios, such as rate limiting.
131+
/// - Parameter policy: The backoff policy to use.
132+
func retryBackoffPolicy(_ policy: RetryBackoffPolicy) -> APIClient {
133+
configs(\.retryBackoffPolicy, policy)
134+
}
68135
}
69136

70137
public extension APIClient {
71138

72-
/// Retries the request if it fails.
73-
/// - Parameters:
74-
/// - retryKey: A closure that takes the request and returns a key used to group retries. Requests with the same key will share retry decisions.
75-
/// 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.
139+
/// Retries the request when necessary, based on the configured retry conditions and limits.
76140
/// - 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.
78-
func retry(
79-
retryKey: ((HTTPRequestComponents) -> AnyHashable)? = nil
80-
) -> APIClient {
81-
httpClientMiddleware(RetryMiddleware(retryKey: retryKey))
141+
/// - Tip: You can customize the retry behavior by setting the `retryCondition`, `retryLimit`, `retryInterval`, and `retryBackoffPolicy` configurations.
142+
func retry() -> APIClient {
143+
httpClientMiddleware(retryMiddleware())
82144
}
83145
}
84146

@@ -179,14 +241,14 @@ public struct RetryRequestCondition {
179241
/// This condition combines the following:
180242
/// - `requestMethodIsSafe`: Ensures that only safe HTTP methods are retried.
181243
/// - `requestFailed`: Retries when the request fails due to network errors.
182-
/// - `retryableStatusCode`: Retries when the response status code indicates a transient failure.
244+
/// - `retryStatusCode`: Retries when the response status code indicates a transient failure.
183245
/// This default behavior is suitable for most scenarios where idempotent requests should be retried on failure.
184246
public static var `default`: RetryRequestCondition {
185247
.and(
186248
.requestMethodIsSafe,
187249
.or(
188250
.requestFailed,
189-
.retryableStatusCode
251+
.retryStatusCode
190252
)
191253
)
192254
}
@@ -207,7 +269,7 @@ public struct RetryRequestCondition {
207269
}
208270

209271
/// 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])
272+
public static let retryStatusCode = RetryRequestCondition.statusCodes([408, 421, 429, 500, 502, 503, 504, 509])
211273

212274
/// A `RetryRequestCondition` that retries the request when the response status code is `429 Too Many Requests`.
213275
public static let rateLimitExceeded = RetryRequestCondition.statusCodes(.tooManyRequests)
@@ -240,9 +302,32 @@ public struct RetryRequestCondition {
240302
}
241303
}
242304

243-
private struct RetryMiddleware: HTTPClientMiddleware {
305+
/// Backoff policy described with closures.
306+
public struct RetryBackoffPolicy {
307+
308+
/// Hash all requests that must share the same cooldown window.
309+
/// Example: host-only, or host+token, or host+bucket(path prefix).
310+
let scopeHash: (_ request: HTTPRequestComponents) -> AnyHashable?
311+
312+
/// Decide if the response must trigger a global backoff for the scope.
313+
let isGlobalBackoff: (_ request: HTTPRequestComponents, _ response: HTTPResponse) -> Bool
314+
315+
public init(
316+
scopeHash: @escaping (_ request : HTTPRequestComponents) -> AnyHashable?,
317+
isGlobalBackoff: @escaping (_ request: HTTPRequestComponents, _ response: HTTPResponse) -> Bool
318+
) {
319+
self.isGlobalBackoff = isGlobalBackoff
320+
self.scopeHash = scopeHash
321+
}
322+
323+
public static let `default` = RetryBackoffPolicy { req in
324+
req.urlComponents.host
325+
} isGlobalBackoff: { _, resp in
326+
Set([429, 503]).contains(resp.status.code)
327+
}
328+
}
244329

245-
let retryKey: ((HTTPRequestComponents) -> AnyHashable)?
330+
private struct retryMiddleware: HTTPClientMiddleware {
246331

247332
func execute<T>(
248333
request: HTTPRequestComponents,
@@ -252,10 +337,12 @@ private struct RetryMiddleware: HTTPClientMiddleware {
252337
let condition = configs.retryCondition
253338
let limit = configs.retryLimit
254339
let interval = configs.retryInterval
255-
if let retryKey {
256-
await waitForSynchronizedAccess(id: retryKey(request), of: Void.self)
340+
let backoffPolicy = configs.retryBackoffPolicy
341+
if let hash = backoffPolicy.scopeHash(request) {
342+
await waitForSynchronizedAccess(id: hash, of: Void.self)
257343
}
258344
var count = 0
345+
var resp: HTTPResponse?
259346
var retryAfterHeader: TimeInterval = 0
260347

261348
func needRetry(_ result: Result<HTTPResponse, Error>) -> Bool {
@@ -272,8 +359,8 @@ private struct RetryMiddleware: HTTPClientMiddleware {
272359
if count > 0 {
273360
let interval = UInt64(max(retryAfterHeader, interval(count - 1)) * 1_000_000_000)
274361
if interval > 0 {
275-
if let retryKey {
276-
try await withThrowingSynchronizedAccess(id: retryKey(request)) {
362+
if let resp, let hash = backoffPolicy.scopeHash(request), backoffPolicy.isGlobalBackoff(request, resp) {
363+
try await withThrowingSynchronizedAccess(id: hash) {
277364
try await Task.sleep(nanoseconds: interval)
278365
}
279366
} else {
@@ -289,6 +376,7 @@ private struct RetryMiddleware: HTTPClientMiddleware {
289376
while true {
290377
do {
291378
let (data, response) = try await retry()
379+
resp = response
292380
retryAfterHeader = response.headerFields[.retryAfter].flatMap {
293381
decodeRetryAfterHeader($0, formatter: configs.retryAfterHeaderDateFormatter)
294382
} ?? 0

0 commit comments

Comments
 (0)