@@ -5,24 +5,72 @@ import FoundationNetworking
55import HTTPTypes
66import 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+
856public 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
3994public 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
70137public 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