Skip to content

Commit e916ba1

Browse files
committed
Retry strategy
1 parent ff91276 commit e916ba1

8 files changed

Lines changed: 868 additions & 33 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift HTTP API Proposal open source project
4+
//
5+
// Copyright (c) 2026 Apple Inc. and the Swift HTTP API Proposal project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Swift HTTP API Proposal project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *)
16+
extension HTTPClientCapability {
17+
/// A protocol for HTTP request options that support retry policies.
18+
public protocol RetryStrategy: RequestOptions {
19+
/// The retry strategy to apply before exposing the response to the caller.
20+
var retryStrategy: (any HTTPClientRetryStrategy)? { get set }
21+
}
22+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift HTTP API Proposal open source project
4+
//
5+
// Copyright (c) 2026 Apple Inc. and the Swift HTTP API Proposal project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Swift HTTP API Proposal project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
/// An action that determines whether an HTTP client should retry a request.
16+
@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *)
17+
public enum HTTPClientRetryAction: Sendable {
18+
/// Do not retry the request.
19+
case doNotRetry
20+
21+
/// Retry the request after an optional delay.
22+
case retry(HTTPRequest, after: Duration)
23+
}
Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift HTTP API Proposal open source project
4+
//
5+
// Copyright (c) 2026 Apple Inc. and the Swift HTTP API Proposal project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Swift HTTP API Proposal project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
#if canImport(Foundation)
16+
import Foundation
17+
#endif
18+
19+
/// Describes whether a request body can be replayed across retry attempts.
20+
@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *)
21+
public enum HTTPClientRequestBodyReplayability: Sendable {
22+
/// The request has no body.
23+
case none
24+
25+
/// The request body can be replayed from the beginning.
26+
case restartable
27+
28+
/// The request body can be replayed from any offset.
29+
case seekable
30+
}
31+
32+
/// Context for a retry decision.
33+
@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *)
34+
public struct HTTPClientRetryContext: Sendable {
35+
/// The request that produced the current response or error.
36+
public var request: HTTPRequest
37+
38+
/// Whether the request body can be replayed.
39+
public var bodyReplayability: HTTPClientRequestBodyReplayability
40+
41+
/// The current attempt number, starting at `1`.
42+
public var attempt: Int
43+
44+
public init(
45+
request: HTTPRequest,
46+
bodyReplayability: HTTPClientRequestBodyReplayability,
47+
attempt: Int
48+
) {
49+
self.request = request
50+
self.bodyReplayability = bodyReplayability
51+
self.attempt = attempt
52+
}
53+
}
54+
55+
/// A policy object that decides whether a request should be retried.
56+
///
57+
/// Retry hooks are only consulted before the response is handed to the caller. Once the
58+
/// response handler starts consuming the response, the request is no longer retryable.
59+
@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *)
60+
public protocol HTTPClientRetryStrategy: Sendable {
61+
/// Decides whether to retry after receiving a response.
62+
func retryRequest(
63+
after response: HTTPResponse,
64+
context: HTTPClientRetryContext
65+
) async throws -> HTTPClientRetryAction
66+
67+
/// Decides whether to retry after a transport-level failure before the response is exposed.
68+
func retryRequest(
69+
after error: any Error,
70+
context: HTTPClientRetryContext
71+
) async throws -> HTTPClientRetryAction
72+
}
73+
74+
@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *)
75+
extension HTTPClientRetryStrategy {
76+
public func retryRequest(
77+
after response: HTTPResponse,
78+
context: HTTPClientRetryContext
79+
) async throws -> HTTPClientRetryAction {
80+
.doNotRetry
81+
}
82+
83+
public func retryRequest(
84+
after error: any Error,
85+
context: HTTPClientRetryContext
86+
) async throws -> HTTPClientRetryAction {
87+
.doNotRetry
88+
}
89+
}
90+
91+
/// A backoff schedule for retrying requests.
92+
@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *)
93+
public struct HTTPClientRetryBackoff: Sendable {
94+
private enum Storage: Sendable {
95+
case immediate
96+
case constant(Duration)
97+
case linear(Duration)
98+
case exponential(initialDelay: Duration, multiplier: Int)
99+
}
100+
101+
private let storage: Storage
102+
103+
/// The maximum total number of attempts, including the first attempt.
104+
public let maximumNumberOfAttempts: Int
105+
106+
private init(storage: Storage, maximumNumberOfAttempts: Int) {
107+
precondition(maximumNumberOfAttempts >= 1, "maximumNumberOfAttempts must be at least 1")
108+
self.storage = storage
109+
self.maximumNumberOfAttempts = maximumNumberOfAttempts
110+
}
111+
112+
/// Retries immediately until the maximum number of attempts is reached.
113+
public static func immediate(maximumNumberOfAttempts: Int) -> Self {
114+
.init(storage: .immediate, maximumNumberOfAttempts: maximumNumberOfAttempts)
115+
}
116+
117+
/// Retries with a constant delay.
118+
public static func constant(_ delay: Duration, maximumNumberOfAttempts: Int) -> Self {
119+
.init(storage: .constant(delay), maximumNumberOfAttempts: maximumNumberOfAttempts)
120+
}
121+
122+
/// Retries with a linearly increasing delay.
123+
public static func linear(_ delay: Duration, maximumNumberOfAttempts: Int) -> Self {
124+
.init(storage: .linear(delay), maximumNumberOfAttempts: maximumNumberOfAttempts)
125+
}
126+
127+
/// Retries with an exponentially increasing delay.
128+
public static func exponential(
129+
initialDelay: Duration,
130+
multiplier: Int = 2,
131+
maximumNumberOfAttempts: Int
132+
) -> Self {
133+
precondition(multiplier >= 1, "multiplier must be at least 1")
134+
return .init(
135+
storage: .exponential(initialDelay: initialDelay, multiplier: multiplier),
136+
maximumNumberOfAttempts: maximumNumberOfAttempts
137+
)
138+
}
139+
140+
/// Returns the delay before retrying after the specified attempt.
141+
///
142+
/// For example, if `attempt` is `1`, the returned duration is the delay before the second attempt.
143+
public func delay(afterAttempt attempt: Int) -> Duration? {
144+
guard attempt >= 1, attempt < self.maximumNumberOfAttempts else {
145+
return nil
146+
}
147+
148+
switch self.storage {
149+
case .immediate:
150+
return .zero
151+
case .constant(let delay):
152+
return delay
153+
case .linear(let delay):
154+
return delay * attempt
155+
case .exponential(let initialDelay, let multiplier):
156+
return initialDelay * Self.power(multiplier, attempt - 1)
157+
}
158+
}
159+
160+
private static func power(_ base: Int, _ exponent: Int) -> Int {
161+
guard exponent > 0 else {
162+
return 1
163+
}
164+
var result = 1
165+
for _ in 0..<exponent {
166+
result *= base
167+
}
168+
return result
169+
}
170+
}
171+
172+
/// A retry strategy backed by closures.
173+
@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *)
174+
public struct HTTPClientConditionalRetryStrategy: HTTPClientRetryStrategy {
175+
public typealias ResponseHandler = @Sendable (HTTPResponse, HTTPClientRetryContext) async throws -> HTTPClientRetryAction
176+
public typealias ErrorHandler = @Sendable (any Error, HTTPClientRetryContext) async throws -> HTTPClientRetryAction
177+
178+
private let responseHandler: ResponseHandler?
179+
private let errorHandler: ErrorHandler?
180+
181+
public init(
182+
onResponse responseHandler: ResponseHandler? = nil,
183+
onError errorHandler: ErrorHandler? = nil
184+
) {
185+
self.responseHandler = responseHandler
186+
self.errorHandler = errorHandler
187+
}
188+
189+
public func retryRequest(
190+
after response: HTTPResponse,
191+
context: HTTPClientRetryContext
192+
) async throws -> HTTPClientRetryAction {
193+
try await self.responseHandler?(response, context) ?? .doNotRetry
194+
}
195+
196+
public func retryRequest(
197+
after error: any Error,
198+
context: HTTPClientRetryContext
199+
) async throws -> HTTPClientRetryAction {
200+
try await self.errorHandler?(error, context) ?? .doNotRetry
201+
}
202+
}
203+
204+
/// Retries idempotent requests for transient server responses and selected transport errors.
205+
@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *)
206+
public struct HTTPClientTransientFailureRetryStrategy: HTTPClientRetryStrategy {
207+
private let currentTimeSinceUnixEpoch: @Sendable () -> Duration
208+
209+
public static let defaultRetryableMethods: Set<HTTPRequest.Method> = [
210+
.get,
211+
.head,
212+
.put,
213+
.delete,
214+
.options,
215+
.trace,
216+
]
217+
218+
public static let defaultRetryableStatusCodes: Set<HTTPResponse.Status> = [
219+
.requestTimeout,
220+
.tooManyRequests,
221+
.badGateway,
222+
.serviceUnavailable,
223+
.gatewayTimeout,
224+
]
225+
226+
public let backoff: HTTPClientRetryBackoff
227+
public var retryableMethods: Set<HTTPRequest.Method>
228+
public var retryableStatusCodes: Set<HTTPResponse.Status>
229+
public var respectsRetryAfter: Bool
230+
231+
public init(
232+
backoff: HTTPClientRetryBackoff,
233+
retryableMethods: Set<HTTPRequest.Method> = Self.defaultRetryableMethods,
234+
retryableStatusCodes: Set<HTTPResponse.Status> = Self.defaultRetryableStatusCodes,
235+
respectsRetryAfter: Bool = true
236+
) {
237+
self.init(
238+
backoff: backoff,
239+
retryableMethods: retryableMethods,
240+
retryableStatusCodes: retryableStatusCodes,
241+
respectsRetryAfter: respectsRetryAfter,
242+
currentTimeSinceUnixEpoch: {
243+
.milliseconds(Int64((Date().timeIntervalSince1970 * 1000).rounded()))
244+
}
245+
)
246+
}
247+
248+
package init(
249+
backoff: HTTPClientRetryBackoff,
250+
retryableMethods: Set<HTTPRequest.Method> = Self.defaultRetryableMethods,
251+
retryableStatusCodes: Set<HTTPResponse.Status> = Self.defaultRetryableStatusCodes,
252+
respectsRetryAfter: Bool = true,
253+
currentTimeSinceUnixEpoch: @escaping @Sendable () -> Duration
254+
) {
255+
self.backoff = backoff
256+
self.retryableMethods = retryableMethods
257+
self.retryableStatusCodes = retryableStatusCodes
258+
self.respectsRetryAfter = respectsRetryAfter
259+
self.currentTimeSinceUnixEpoch = currentTimeSinceUnixEpoch
260+
}
261+
262+
public func retryRequest(
263+
after response: HTTPResponse,
264+
context: HTTPClientRetryContext
265+
) async throws -> HTTPClientRetryAction {
266+
guard self.retryableMethods.contains(context.request.method),
267+
self.retryableStatusCodes.contains(response.status)
268+
else {
269+
return .doNotRetry
270+
}
271+
272+
if self.respectsRetryAfter,
273+
let retryAfter = response.headerFields[.retryAfter],
274+
let delay = self.retryAfterDelay(from: retryAfter)
275+
{
276+
return .retry(context.request, after: delay)
277+
}
278+
279+
guard let delay = self.backoff.delay(afterAttempt: context.attempt) else {
280+
return .doNotRetry
281+
}
282+
return .retry(context.request, after: delay)
283+
}
284+
285+
public func retryRequest(
286+
after error: any Error,
287+
context: HTTPClientRetryContext
288+
) async throws -> HTTPClientRetryAction {
289+
guard self.retryableMethods.contains(context.request.method),
290+
Self.isRetryableTransportError(error),
291+
let delay = self.backoff.delay(afterAttempt: context.attempt)
292+
else {
293+
return .doNotRetry
294+
}
295+
return .retry(context.request, after: delay)
296+
}
297+
298+
static func isRetryableTransportError(_ error: any Error) -> Bool {
299+
if error is CancellationError {
300+
return false
301+
}
302+
guard let urlError = error as? URLError else {
303+
return false
304+
}
305+
switch urlError.code {
306+
case .timedOut,
307+
.cannotFindHost,
308+
.cannotConnectToHost,
309+
.dnsLookupFailed,
310+
.networkConnectionLost,
311+
.resourceUnavailable,
312+
.notConnectedToInternet:
313+
return true
314+
default:
315+
return false
316+
}
317+
}
318+
319+
private func retryAfterDelay(from value: String) -> Duration? {
320+
let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
321+
if let seconds = Int(trimmedValue) {
322+
// RFC 9110 Section 10.2.3 defines `Retry-After` as either an `HTTP-date`
323+
// or `delay-seconds`, where `delay-seconds` is a non-negative integer.
324+
return .seconds(max(seconds, 0))
325+
}
326+
327+
guard let date = HTTPDateFormatter().date(from: trimmedValue) else {
328+
return nil
329+
}
330+
331+
let delay = Self.timeSinceUnixEpoch(for: date) - self.currentTimeSinceUnixEpoch()
332+
return max(delay, .zero)
333+
}
334+
335+
private static func timeSinceUnixEpoch(for date: Date) -> Duration {
336+
.milliseconds(Int64((date.timeIntervalSince1970 * 1000).rounded()))
337+
}
338+
}

0 commit comments

Comments
 (0)