From e916ba142bf92ca46cbb7942cc099af1775e5c3f Mon Sep 17 00:00:00 2001 From: Laszlo Teveli Date: Tue, 10 Mar 2026 23:26:21 +0100 Subject: [PATCH] Retry strategy --- .../HTTPClientCapability+RetryStrategy.swift | 22 ++ .../HTTPClient/HTTPClientRetryAction.swift | 23 ++ .../HTTPClient/HTTPClientRetryStrategy.swift | 338 ++++++++++++++++++ Sources/HTTPClient/HTTPDateFormatter.swift | 72 ++++ Sources/HTTPClient/HTTPRequestOptions.swift | 5 +- .../URLSession/URLSessionHTTPClient.swift | 133 +++++-- .../HTTPServerForTesting/TestHTTPServer.swift | 39 ++ .../HTTPClientTests/RetryStrategyTests.swift | 269 ++++++++++++++ 8 files changed, 868 insertions(+), 33 deletions(-) create mode 100644 Sources/HTTPClient/HTTPClientCapability+RetryStrategy.swift create mode 100644 Sources/HTTPClient/HTTPClientRetryAction.swift create mode 100644 Sources/HTTPClient/HTTPClientRetryStrategy.swift create mode 100644 Sources/HTTPClient/HTTPDateFormatter.swift create mode 100644 Tests/HTTPClientTests/RetryStrategyTests.swift diff --git a/Sources/HTTPClient/HTTPClientCapability+RetryStrategy.swift b/Sources/HTTPClient/HTTPClientCapability+RetryStrategy.swift new file mode 100644 index 0000000..5f14fb0 --- /dev/null +++ b/Sources/HTTPClient/HTTPClientCapability+RetryStrategy.swift @@ -0,0 +1,22 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP API Proposal open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP API Proposal project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP API Proposal project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension HTTPClientCapability { + /// A protocol for HTTP request options that support retry policies. + public protocol RetryStrategy: RequestOptions { + /// The retry strategy to apply before exposing the response to the caller. + var retryStrategy: (any HTTPClientRetryStrategy)? { get set } + } +} diff --git a/Sources/HTTPClient/HTTPClientRetryAction.swift b/Sources/HTTPClient/HTTPClientRetryAction.swift new file mode 100644 index 0000000..0f65f3a --- /dev/null +++ b/Sources/HTTPClient/HTTPClientRetryAction.swift @@ -0,0 +1,23 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP API Proposal open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP API Proposal project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP API Proposal project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// An action that determines whether an HTTP client should retry a request. +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +public enum HTTPClientRetryAction: Sendable { + /// Do not retry the request. + case doNotRetry + + /// Retry the request after an optional delay. + case retry(HTTPRequest, after: Duration) +} diff --git a/Sources/HTTPClient/HTTPClientRetryStrategy.swift b/Sources/HTTPClient/HTTPClientRetryStrategy.swift new file mode 100644 index 0000000..be51cae --- /dev/null +++ b/Sources/HTTPClient/HTTPClientRetryStrategy.swift @@ -0,0 +1,338 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP API Proposal open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP API Proposal project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP API Proposal project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if canImport(Foundation) +import Foundation +#endif + +/// Describes whether a request body can be replayed across retry attempts. +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +public enum HTTPClientRequestBodyReplayability: Sendable { + /// The request has no body. + case none + + /// The request body can be replayed from the beginning. + case restartable + + /// The request body can be replayed from any offset. + case seekable +} + +/// Context for a retry decision. +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +public struct HTTPClientRetryContext: Sendable { + /// The request that produced the current response or error. + public var request: HTTPRequest + + /// Whether the request body can be replayed. + public var bodyReplayability: HTTPClientRequestBodyReplayability + + /// The current attempt number, starting at `1`. + public var attempt: Int + + public init( + request: HTTPRequest, + bodyReplayability: HTTPClientRequestBodyReplayability, + attempt: Int + ) { + self.request = request + self.bodyReplayability = bodyReplayability + self.attempt = attempt + } +} + +/// A policy object that decides whether a request should be retried. +/// +/// Retry hooks are only consulted before the response is handed to the caller. Once the +/// response handler starts consuming the response, the request is no longer retryable. +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +public protocol HTTPClientRetryStrategy: Sendable { + /// Decides whether to retry after receiving a response. + func retryRequest( + after response: HTTPResponse, + context: HTTPClientRetryContext + ) async throws -> HTTPClientRetryAction + + /// Decides whether to retry after a transport-level failure before the response is exposed. + func retryRequest( + after error: any Error, + context: HTTPClientRetryContext + ) async throws -> HTTPClientRetryAction +} + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension HTTPClientRetryStrategy { + public func retryRequest( + after response: HTTPResponse, + context: HTTPClientRetryContext + ) async throws -> HTTPClientRetryAction { + .doNotRetry + } + + public func retryRequest( + after error: any Error, + context: HTTPClientRetryContext + ) async throws -> HTTPClientRetryAction { + .doNotRetry + } +} + +/// A backoff schedule for retrying requests. +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +public struct HTTPClientRetryBackoff: Sendable { + private enum Storage: Sendable { + case immediate + case constant(Duration) + case linear(Duration) + case exponential(initialDelay: Duration, multiplier: Int) + } + + private let storage: Storage + + /// The maximum total number of attempts, including the first attempt. + public let maximumNumberOfAttempts: Int + + private init(storage: Storage, maximumNumberOfAttempts: Int) { + precondition(maximumNumberOfAttempts >= 1, "maximumNumberOfAttempts must be at least 1") + self.storage = storage + self.maximumNumberOfAttempts = maximumNumberOfAttempts + } + + /// Retries immediately until the maximum number of attempts is reached. + public static func immediate(maximumNumberOfAttempts: Int) -> Self { + .init(storage: .immediate, maximumNumberOfAttempts: maximumNumberOfAttempts) + } + + /// Retries with a constant delay. + public static func constant(_ delay: Duration, maximumNumberOfAttempts: Int) -> Self { + .init(storage: .constant(delay), maximumNumberOfAttempts: maximumNumberOfAttempts) + } + + /// Retries with a linearly increasing delay. + public static func linear(_ delay: Duration, maximumNumberOfAttempts: Int) -> Self { + .init(storage: .linear(delay), maximumNumberOfAttempts: maximumNumberOfAttempts) + } + + /// Retries with an exponentially increasing delay. + public static func exponential( + initialDelay: Duration, + multiplier: Int = 2, + maximumNumberOfAttempts: Int + ) -> Self { + precondition(multiplier >= 1, "multiplier must be at least 1") + return .init( + storage: .exponential(initialDelay: initialDelay, multiplier: multiplier), + maximumNumberOfAttempts: maximumNumberOfAttempts + ) + } + + /// Returns the delay before retrying after the specified attempt. + /// + /// For example, if `attempt` is `1`, the returned duration is the delay before the second attempt. + public func delay(afterAttempt attempt: Int) -> Duration? { + guard attempt >= 1, attempt < self.maximumNumberOfAttempts else { + return nil + } + + switch self.storage { + case .immediate: + return .zero + case .constant(let delay): + return delay + case .linear(let delay): + return delay * attempt + case .exponential(let initialDelay, let multiplier): + return initialDelay * Self.power(multiplier, attempt - 1) + } + } + + private static func power(_ base: Int, _ exponent: Int) -> Int { + guard exponent > 0 else { + return 1 + } + var result = 1 + for _ in 0.. HTTPClientRetryAction + public typealias ErrorHandler = @Sendable (any Error, HTTPClientRetryContext) async throws -> HTTPClientRetryAction + + private let responseHandler: ResponseHandler? + private let errorHandler: ErrorHandler? + + public init( + onResponse responseHandler: ResponseHandler? = nil, + onError errorHandler: ErrorHandler? = nil + ) { + self.responseHandler = responseHandler + self.errorHandler = errorHandler + } + + public func retryRequest( + after response: HTTPResponse, + context: HTTPClientRetryContext + ) async throws -> HTTPClientRetryAction { + try await self.responseHandler?(response, context) ?? .doNotRetry + } + + public func retryRequest( + after error: any Error, + context: HTTPClientRetryContext + ) async throws -> HTTPClientRetryAction { + try await self.errorHandler?(error, context) ?? .doNotRetry + } +} + +/// Retries idempotent requests for transient server responses and selected transport errors. +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +public struct HTTPClientTransientFailureRetryStrategy: HTTPClientRetryStrategy { + private let currentTimeSinceUnixEpoch: @Sendable () -> Duration + + public static let defaultRetryableMethods: Set = [ + .get, + .head, + .put, + .delete, + .options, + .trace, + ] + + public static let defaultRetryableStatusCodes: Set = [ + .requestTimeout, + .tooManyRequests, + .badGateway, + .serviceUnavailable, + .gatewayTimeout, + ] + + public let backoff: HTTPClientRetryBackoff + public var retryableMethods: Set + public var retryableStatusCodes: Set + public var respectsRetryAfter: Bool + + public init( + backoff: HTTPClientRetryBackoff, + retryableMethods: Set = Self.defaultRetryableMethods, + retryableStatusCodes: Set = Self.defaultRetryableStatusCodes, + respectsRetryAfter: Bool = true + ) { + self.init( + backoff: backoff, + retryableMethods: retryableMethods, + retryableStatusCodes: retryableStatusCodes, + respectsRetryAfter: respectsRetryAfter, + currentTimeSinceUnixEpoch: { + .milliseconds(Int64((Date().timeIntervalSince1970 * 1000).rounded())) + } + ) + } + + package init( + backoff: HTTPClientRetryBackoff, + retryableMethods: Set = Self.defaultRetryableMethods, + retryableStatusCodes: Set = Self.defaultRetryableStatusCodes, + respectsRetryAfter: Bool = true, + currentTimeSinceUnixEpoch: @escaping @Sendable () -> Duration + ) { + self.backoff = backoff + self.retryableMethods = retryableMethods + self.retryableStatusCodes = retryableStatusCodes + self.respectsRetryAfter = respectsRetryAfter + self.currentTimeSinceUnixEpoch = currentTimeSinceUnixEpoch + } + + public func retryRequest( + after response: HTTPResponse, + context: HTTPClientRetryContext + ) async throws -> HTTPClientRetryAction { + guard self.retryableMethods.contains(context.request.method), + self.retryableStatusCodes.contains(response.status) + else { + return .doNotRetry + } + + if self.respectsRetryAfter, + let retryAfter = response.headerFields[.retryAfter], + let delay = self.retryAfterDelay(from: retryAfter) + { + return .retry(context.request, after: delay) + } + + guard let delay = self.backoff.delay(afterAttempt: context.attempt) else { + return .doNotRetry + } + return .retry(context.request, after: delay) + } + + public func retryRequest( + after error: any Error, + context: HTTPClientRetryContext + ) async throws -> HTTPClientRetryAction { + guard self.retryableMethods.contains(context.request.method), + Self.isRetryableTransportError(error), + let delay = self.backoff.delay(afterAttempt: context.attempt) + else { + return .doNotRetry + } + return .retry(context.request, after: delay) + } + + static func isRetryableTransportError(_ error: any Error) -> Bool { + if error is CancellationError { + return false + } + guard let urlError = error as? URLError else { + return false + } + switch urlError.code { + case .timedOut, + .cannotFindHost, + .cannotConnectToHost, + .dnsLookupFailed, + .networkConnectionLost, + .resourceUnavailable, + .notConnectedToInternet: + return true + default: + return false + } + } + + private func retryAfterDelay(from value: String) -> Duration? { + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let seconds = Int(trimmedValue) { + // RFC 9110 Section 10.2.3 defines `Retry-After` as either an `HTTP-date` + // or `delay-seconds`, where `delay-seconds` is a non-negative integer. + return .seconds(max(seconds, 0)) + } + + guard let date = HTTPDateFormatter().date(from: trimmedValue) else { + return nil + } + + let delay = Self.timeSinceUnixEpoch(for: date) - self.currentTimeSinceUnixEpoch() + return max(delay, .zero) + } + + private static func timeSinceUnixEpoch(for date: Date) -> Duration { + .milliseconds(Int64((date.timeIntervalSince1970 * 1000).rounded())) + } +} diff --git a/Sources/HTTPClient/HTTPDateFormatter.swift b/Sources/HTTPClient/HTTPDateFormatter.swift new file mode 100644 index 0000000..005fc87 --- /dev/null +++ b/Sources/HTTPClient/HTTPDateFormatter.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP API Proposal open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP API Proposal project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP API Proposal project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if canImport(Foundation) +public import Foundation + +/// Formats and parses RFC 9110 HTTP-date values. +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +public struct HTTPDateFormatter: Sendable { + public init() {} + + /// Formats a date using the preferred IMF-fixdate form. + public func string(from date: Date) -> String { + DateFormatter.httpImfFixdate.string(from: date) + } + + /// Parses an HTTP-date, accepting the preferred IMF-fixdate form and the two obsolete forms + /// that recipients are still required to accept. + public func date(from value: String) -> Date? { + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + for formatter in [ + DateFormatter.httpImfFixdate, + DateFormatter.httpRfc850Date, + DateFormatter.httpAsctimeDate, + ] { + if let date = formatter.date(from: trimmedValue) { + return date + } + } + return nil + } +} + +private extension DateFormatter { + // RFC 9110 Section 5.6.7 preferred IMF-fixdate form: + // Sun, 06 Nov 1994 08:49:37 GMT + static let httpImfFixdate = DateFormatter.makeHTTPDateFormatter( + dateFormat: "EEE',' dd MMM yyyy HH':'mm':'ss zzz" + ) + + // RFC 9110 Section 5.6.7 obsolete RFC 850 form that recipients must still accept: + // Sunday, 06-Nov-94 08:49:37 GMT + static let httpRfc850Date = DateFormatter.makeHTTPDateFormatter( + dateFormat: "EEEE',' dd-MMM-yy HH':'mm':'ss zzz" + ) + + // RFC 9110 Section 5.6.7 obsolete ANSI C `asctime()` form: + // Sun Nov 6 08:49:37 1994 + static let httpAsctimeDate = DateFormatter.makeHTTPDateFormatter( + dateFormat: "EEE MMM d HH':'mm':'ss yyyy" + ) + + private static func makeHTTPDateFormatter(dateFormat: String) -> DateFormatter { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = dateFormat + return formatter + } +} +#endif diff --git a/Sources/HTTPClient/HTTPRequestOptions.swift b/Sources/HTTPClient/HTTPRequestOptions.swift index bff71a5..ce8236e 100644 --- a/Sources/HTTPClient/HTTPRequestOptions.swift +++ b/Sources/HTTPClient/HTTPRequestOptions.swift @@ -16,10 +16,11 @@ public import NetworkTypes /// The options for the default HTTP client implementation. @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) -public struct HTTPRequestOptions: HTTPClientCapability.RedirectionHandler, HTTPClientCapability.TLSVersionSelection, - HTTPClientCapability.DeclarativePathSelection +public struct HTTPRequestOptions: HTTPClientCapability.RedirectionHandler, HTTPClientCapability.RetryStrategy, + HTTPClientCapability.TLSVersionSelection, HTTPClientCapability.DeclarativePathSelection { public var redirectionHandler: (any HTTPClientRedirectionHandler)? = nil + public var retryStrategy: (any HTTPClientRetryStrategy)? = nil #if canImport(Darwin) public var serverTrustHandler: (any HTTPClientServerTrustHandler)? = nil diff --git a/Sources/HTTPClient/URLSession/URLSessionHTTPClient.swift b/Sources/HTTPClient/URLSession/URLSessionHTTPClient.swift index 02c2e46..ca652f4 100644 --- a/Sources/HTTPClient/URLSession/URLSessionHTTPClient.swift +++ b/Sources/HTTPClient/URLSession/URLSessionHTTPClient.swift @@ -247,48 +247,119 @@ final class URLSessionHTTPClient: HTTPClient, IdleTimerEntryProvider { return urlRequest as URLRequest } + private func requestBodyReplayability( + _ body: HTTPClientRequestBody? + ) -> HTTPClientRequestBodyReplayability { + guard let body else { + return .none + } + return body.isSeekable ? .seekable : .restartable + } + func perform( request: HTTPRequest, body: consuming HTTPClientRequestBody?, options: HTTPRequestOptions, responseHandler: (HTTPResponse, consuming ResponseConcludingReader) async throws -> Return ) async throws -> Return { - guard request.schemeSupported else { - throw HTTPTypeConversionError.unsupportedScheme - } - let request = try self.request(for: request, options: options) - let session = self.session(for: options) - let task: URLSessionTask - let delegateBridge: URLSessionTaskDelegateBridge - if let body { - task = session.startTask().uploadTask(withStreamedRequest: request) - delegateBridge = URLSessionTaskDelegateBridge(task: task, body: body) - } else { - task = session.startTask().dataTask(with: request) - delegateBridge = URLSessionTaskDelegateBridge(task: task, body: nil) - } - task.delegate = delegateBridge - task.resume() - defer { - session.finishTask() - } - // withTaskCancellationHandler does not support ~Copyable result type - var result: Result? = nil - try await withTaskCancellationHandler { + var currentRequest = request + let bodyReplayability = self.requestBodyReplayability(body) + var attempt = 1 + + while true { + guard currentRequest.schemeSupported else { + throw HTTPTypeConversionError.unsupportedScheme + } + let urlRequest = try self.request(for: currentRequest, options: options) + let session = self.session(for: options) + let task: URLSessionTask + let delegateBridge: URLSessionTaskDelegateBridge + if let body { + task = session.startTask().uploadTask(withStreamedRequest: urlRequest) + delegateBridge = URLSessionTaskDelegateBridge(task: task, body: body) + } else { + task = session.startTask().dataTask(with: urlRequest) + delegateBridge = URLSessionTaskDelegateBridge(task: task, body: nil) + } + task.delegate = delegateBridge + task.resume() + + let retryContext = HTTPClientRetryContext( + request: currentRequest, + bodyReplayability: bodyReplayability, + attempt: attempt + ) + + // withTaskCancellationHandler does not support ~Copyable result type + var result: Result? = nil + var retryAction: HTTPClientRetryAction? = nil + var consultErrorRetryStrategy = true do { - let response = try await delegateBridge.processDelegateCallbacksBeforeResponse(options) - guard let response = (response as? HTTPURLResponse)?.httpResponse else { - throw HTTPTypeConversionError.failedToConvertURLTypeToHTTPTypes + try await withTaskCancellationHandler { + do { + let response = try await delegateBridge.processDelegateCallbacksBeforeResponse(options) + guard let response = (response as? HTTPURLResponse)?.httpResponse else { + throw HTTPTypeConversionError.failedToConvertURLTypeToHTTPTypes + } + + if let retryStrategy = options.retryStrategy { + consultErrorRetryStrategy = false + let action = try await retryStrategy.retryRequest(after: response, context: retryContext) + if case .retry = action { + retryAction = action + } + } + + if retryAction == nil { + result = .success(try await responseHandler(response, delegateBridge)) + } + } catch { + if consultErrorRetryStrategy, let retryStrategy = options.retryStrategy { + let action = try await retryStrategy.retryRequest(after: error, context: retryContext) + switch action { + case .doNotRetry: + result = .failure(error) + case .retry: + retryAction = action + } + } else { + result = .failure(error) + } + } + + if retryAction == nil { + do { + try await delegateBridge.processDelegateCallbacksAfterResponse(options) + } catch { + result = .failure(error) + } + } else { + try? await delegateBridge.processDelegateCallbacksAfterResponse(options) + } + } onCancel: { + task.cancel() } - result = .success(try await responseHandler(response, delegateBridge)) } catch { - result = .failure(error) + session.finishTask() + throw error + } + session.finishTask() + + guard let retryAction else { + return try result!.get() + } + + switch retryAction { + case .retry(let request, let delay): + currentRequest = request + attempt += 1 + if delay > .zero { + try await Task.sleep(for: delay) + } + case .doNotRetry: + return try result!.get() } - try await delegateBridge.processDelegateCallbacksAfterResponse(options) - } onCancel: { - task.cancel() } - return try result!.get() } var defaultRequestOptions: HTTPRequestOptions { diff --git a/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift b/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift index 4bc4e79..4934b90 100644 --- a/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift +++ b/Sources/HTTPClientConformance/HTTPServerForTesting/TestHTTPServer.swift @@ -74,9 +74,23 @@ struct ETag: Sendable & ~Copyable { } } +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +struct AttemptCounter: Sendable & ~Copyable { + let count: Mutex = .init(0) + + func next() -> Int { + self.count.withLock { + $0 += 1 + return $0 + } + } +} + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) func serve(server: NIOHTTPServer) async throws { let eTag = ETag() + let retryAfterCounter = AttemptCounter() + let retryAfterNoHeaderCounter = AttemptCounter() try await server.serve { request, requestContext, requestBodyAndTrailers, responseSender in // This server expects a path guard let path = request.path else { @@ -407,6 +421,31 @@ func serve(server: NIOHTTPServer) async throws { let data = serverETag.data(using: .ascii)! try await responseBodyAndTrailers.writeAndConclude(data.span, finalElement: nil) } + case "/retry_after": + let attempt = retryAfterCounter.next() + if attempt == 1 { + let responseBodyAndTrailers = try await responseSender.send( + .init( + status: .serviceUnavailable, + headerFields: [ + .retryAfter: "0" + ] + ) + ) + try await responseBodyAndTrailers.writeAndConclude("1".utf8.span, finalElement: nil) + } else { + let responseBodyAndTrailers = try await responseSender.send(.init(status: .ok)) + try await responseBodyAndTrailers.writeAndConclude(String(attempt).utf8.span, finalElement: nil) + } + case "/retry_after_no_header": + let attempt = retryAfterNoHeaderCounter.next() + if attempt == 1 { + let responseBodyAndTrailers = try await responseSender.send(.init(status: .serviceUnavailable)) + try await responseBodyAndTrailers.writeAndConclude("1".utf8.span, finalElement: nil) + } else { + let responseBodyAndTrailers = try await responseSender.send(.init(status: .ok)) + try await responseBodyAndTrailers.writeAndConclude(String(attempt).utf8.span, finalElement: nil) + } default: let writer = try await responseSender.send(HTTPResponse(status: .internalServerError)) try await writer.writeAndConclude("Unknown path".utf8.span, finalElement: nil) diff --git a/Tests/HTTPClientTests/RetryStrategyTests.swift b/Tests/HTTPClientTests/RetryStrategyTests.swift new file mode 100644 index 0000000..c7d1b9c --- /dev/null +++ b/Tests/HTTPClientTests/RetryStrategyTests.swift @@ -0,0 +1,269 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP API Proposal open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP API Proposal project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP API Proposal project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if canImport(Foundation) +import Foundation +#endif +import HTTPClient +import HTTPClientConformance +import Testing + +@Suite +struct RetryStrategyTests { + @Test + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func linearBackoffGrowsPerAttempt() { + let backoff = HTTPClientRetryBackoff.linear(.milliseconds(50), maximumNumberOfAttempts: 4) + + #expect(backoff.delay(afterAttempt: 1) == .milliseconds(50)) + #expect(backoff.delay(afterAttempt: 2) == .milliseconds(100)) + #expect(backoff.delay(afterAttempt: 3) == .milliseconds(150)) + #expect(backoff.delay(afterAttempt: 4) == nil) + } + + @Test + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func exponentialBackoffDoublesPerAttempt() { + let backoff = HTTPClientRetryBackoff.exponential( + initialDelay: .milliseconds(25), + maximumNumberOfAttempts: 5 + ) + + #expect(backoff.delay(afterAttempt: 1) == .milliseconds(25)) + #expect(backoff.delay(afterAttempt: 2) == .milliseconds(50)) + #expect(backoff.delay(afterAttempt: 3) == .milliseconds(100)) + #expect(backoff.delay(afterAttempt: 4) == .milliseconds(200)) + #expect(backoff.delay(afterAttempt: 5) == nil) + } + + @Test + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func transientFailureStrategyHonorsRetryAfterHeader() async throws { + let strategy = HTTPClientTransientFailureRetryStrategy( + backoff: .constant(.seconds(10), maximumNumberOfAttempts: 3) + ) + let request = HTTPRequest(method: .get, scheme: "https", authority: "example.com", path: "/") + let context = HTTPClientRetryContext(request: request, bodyReplayability: .none, attempt: 1) + let response = HTTPResponse( + status: .serviceUnavailable, + headerFields: [ + .retryAfter: "2" + ] + ) + + let action = try await strategy.retryRequest(after: response, context: context) + switch action { + case .retry(let retriedRequest, let delay): + #expect(retriedRequest == request) + #expect(delay == .seconds(2)) + case .doNotRetry: + Issue.record("Expected retry action") + } + } + + @Test + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func transientFailureStrategyParsesRetryAfterHttpDate() async throws { + let now = Duration.seconds(1_700_000_000) + let strategy = HTTPClientTransientFailureRetryStrategy( + backoff: .constant(.seconds(10), maximumNumberOfAttempts: 3), + currentTimeSinceUnixEpoch: { now } + ) + let request = HTTPRequest(method: .get, scheme: "https", authority: "example.com", path: "/") + let context = HTTPClientRetryContext(request: request, bodyReplayability: .none, attempt: 1) + + let formatter = HTTPDateFormatter() + let retryAfterDate = Date(timeIntervalSince1970: 1_700_000_002) + let retryAfterValue = formatter.string(from: retryAfterDate) + let response = HTTPResponse( + status: .tooManyRequests, + headerFields: [ + .retryAfter: retryAfterValue + ] + ) + + let action = try await strategy.retryRequest(after: response, context: context) + switch action { + case .retry(_, let delay): + #expect(delay == .seconds(2)) + case .doNotRetry: + Issue.record("Expected retry action") + } + } + + @Test + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func transientFailureStrategyDoesNotRetryPostByDefault() async throws { + let strategy = HTTPClientTransientFailureRetryStrategy( + backoff: .immediate(maximumNumberOfAttempts: 3) + ) + let request = HTTPRequest(method: .post, scheme: "https", authority: "example.com", path: "/") + let context = HTTPClientRetryContext(request: request, bodyReplayability: .none, attempt: 1) + let response = HTTPResponse(status: .serviceUnavailable) + + let action = try await strategy.retryRequest(after: response, context: context) + switch action { + case .doNotRetry: + break + case .retry: + Issue.record("Expected doNotRetry action") + } + } + + @Test + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func transientFailureStrategyRetriesBadGatewayByDefault() async throws { + let strategy = HTTPClientTransientFailureRetryStrategy( + backoff: .immediate(maximumNumberOfAttempts: 3) + ) + let request = HTTPRequest(method: .get, scheme: "https", authority: "example.com", path: "/") + let context = HTTPClientRetryContext(request: request, bodyReplayability: .none, attempt: 1) + let response = HTTPResponse(status: .badGateway) + + let action = try await strategy.retryRequest(after: response, context: context) + switch action { + case .retry(let retriedRequest, let delay): + #expect(retriedRequest == request) + #expect(delay == .zero) + case .doNotRetry: + Issue.record("Expected retry action") + } + } + + @Test(.enabled(if: testsEnabled)) + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func conditionalRetryCanMutateTheRequest() async throws { + struct RetriedRequest: Decodable { + let headers: [String: [String]] + } + + try await withTestHTTPServer { port in + let request = HTTPRequest( + method: .get, + scheme: "http", + authority: "127.0.0.1:\(port)", + path: "/request" + ) + let retryHeader = HTTPField.Name("X-Retry-Attempt")! + var options = HTTPRequestOptions() + options.retryStrategy = HTTPClientConditionalRetryStrategy(onResponse: { response, context in + guard context.attempt == 1, + response.status == .ok + else { + return .doNotRetry + } + + var request = context.request + request.headerFields[retryHeader] = "2" + return .retry(request, after: .zero) + }) + + try await DefaultHTTPClient.shared.perform( + request: request, + options: options + ) { response, responseBodyAndTrailers in + #expect(response.status == .ok) + let (body, _) = try await responseBodyAndTrailers.collect(upTo: 4096) { span in + String(copying: try UTF8Span(validating: span)) + } + let echoedRequest = try JSONDecoder().decode(RetriedRequest.self, from: Data(body.utf8)) + #expect(echoedRequest.headers["X-Retry-Attempt"] == ["2"]) + } + } + } + + @Test(.enabled(if: testsEnabled)) + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func transientFailureStrategyRetriesRetryAfterResponses() async throws { + try await withTestHTTPServer { port in + let request = HTTPRequest( + method: .get, + scheme: "http", + authority: "127.0.0.1:\(port)", + path: "/retry_after" + ) + var options = HTTPRequestOptions() + options.retryStrategy = HTTPClientTransientFailureRetryStrategy( + backoff: .constant(.milliseconds(10), maximumNumberOfAttempts: 3) + ) + + try await DefaultHTTPClient.shared.perform( + request: request, + options: options + ) { response, responseBodyAndTrailers in + #expect(response.status == .ok) + let (body, _) = try await responseBodyAndTrailers.collect(upTo: 8) { span in + String(copying: try UTF8Span(validating: span)) + } + #expect(body == "2") + } + } + } + + @Test(.enabled(if: testsEnabled)) + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func transientFailureStrategyFallsBackToBackoffWithoutRetryAfter() async throws { + try await withTestHTTPServer { port in + let request = HTTPRequest( + method: .get, + scheme: "http", + authority: "127.0.0.1:\(port)", + path: "/retry_after_no_header" + ) + var options = HTTPRequestOptions() + options.retryStrategy = HTTPClientTransientFailureRetryStrategy( + backoff: .immediate(maximumNumberOfAttempts: 3) + ) + + try await DefaultHTTPClient.shared.perform( + request: request, + options: options + ) { response, responseBodyAndTrailers in + #expect(response.status == .ok) + let (body, _) = try await responseBodyAndTrailers.collect(upTo: 8) { span in + String(copying: try UTF8Span(validating: span)) + } + #expect(body == "2") + } + } + } + + @Test(.enabled(if: testsEnabled)) + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func transientFailureStrategyDoesNotRetryPostResponsesByDefault() async throws { + try await withTestHTTPServer { port in + let request = HTTPRequest( + method: .post, + scheme: "http", + authority: "127.0.0.1:\(port)", + path: "/retry_after" + ) + var options = HTTPRequestOptions() + options.retryStrategy = HTTPClientTransientFailureRetryStrategy( + backoff: .immediate(maximumNumberOfAttempts: 3) + ) + + try await DefaultHTTPClient.shared.perform( + request: request, + options: options + ) { response, responseBodyAndTrailers in + #expect(response.status == .serviceUnavailable) + let (body, _) = try await responseBodyAndTrailers.collect(upTo: 8) { span in + String(copying: try UTF8Span(validating: span)) + } + #expect(body == "1") + } + } + } +}