diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift index a1047fa85..5d2b0e32a 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift @@ -178,11 +178,13 @@ extension HTTPClient { } return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) -> Void in + var requestOptions = RequestOptions.fromClientConfiguration(self.configuration) + requestOptions.apply(stallTimeout: request.stallTimeout) let transaction = Transaction( request: request, - requestOptions: .fromClientConfiguration(self.configuration), + requestOptions: requestOptions, logger: logger, - connectionDeadline: .now() + (self.configuration.timeout.connectionCreationTimeout), + connectionDeadline: .now() + requestOptions.connectionCreationTimeout, preferredEventLoop: eventLoop, responseContinuation: continuation ) diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift index 6729876f3..09d8557a5 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift @@ -46,6 +46,7 @@ extension HTTPClientRequest { var head: HTTPRequestHead var body: Body? var tlsConfiguration: TLSConfiguration? + var stallTimeout: TimeAmount? } } @@ -92,7 +93,8 @@ extension HTTPClientRequest.Prepared { headers: headers ), body: request.body.map { .init($0) }, - tlsConfiguration: request.tlsConfiguration + tlsConfiguration: request.tlsConfiguration, + stallTimeout: request.stallTimeout ) } } @@ -151,6 +153,7 @@ extension HTTPClientRequest { newRequest.headers = headers newRequest.body = body newRequest.localAddress = self.localAddress + newRequest.stallTimeout = self.stallTimeout return newRequest } } diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift index 5fb5f5cb5..32374f097 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift @@ -60,6 +60,22 @@ public struct HTTPClientRequest: Sendable { /// Defaults to `nil` (use client configuration default). public var localAddress: String? + /// The maximum duration any single phase of this request may sit without progress + /// before it is considered stalled and aborted. + /// + /// When set, overrides ``HTTPClient/Configuration/Timeout-swift.struct/connect``, + /// ``HTTPClient/Configuration/Timeout-swift.struct/read``, and + /// ``HTTPClient/Configuration/Timeout-swift.struct/write`` for this request: + /// - acts as the deadline for acquiring a connection (TCP connect + TLS handshake); + /// - bounds the idle gap between reads from the channel once the request is on the wire; + /// - bounds the idle gap between writes into the channel during body upload. + /// + /// The idle timers reset on every byte transferred in their direction, so a slow-but-steady + /// transfer will not trip them; only an actual gap longer than `stallTimeout` will. + /// This is independent of the per-call `deadline`/`timeout`, which bounds the request end-to-end. + /// Defaults to `nil` (use client configuration defaults). + public var stallTimeout: TimeAmount? + public init(url: String) { self.url = url self.method = .GET @@ -67,6 +83,7 @@ public struct HTTPClientRequest: Sendable { self.body = .none self.tlsConfiguration = nil self.localAddress = nil + self.stallTimeout = nil } } diff --git a/Sources/AsyncHTTPClient/ConnectionPool/RequestOptions.swift b/Sources/AsyncHTTPClient/ConnectionPool/RequestOptions.swift index bf33a95bd..6eb99735b 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/RequestOptions.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/RequestOptions.swift @@ -19,6 +19,9 @@ struct RequestOptions { var idleReadTimeout: TimeAmount? /// The maximal `TimeAmount` that is allowed to pass between `write`s into the Channel. var idleWriteTimeout: TimeAmount? + /// The maximum time the request will wait for a usable connection to be established + /// (TCP connect + TLS handshake) before giving up. + var connectionCreationTimeout: TimeAmount /// DNS overrides. var dnsOverride: [String: String] /// The local IP address to bind outgoing connections to. This is typically used on multi-NIC @@ -28,11 +31,13 @@ struct RequestOptions { init( idleReadTimeout: TimeAmount?, idleWriteTimeout: TimeAmount?, + connectionCreationTimeout: TimeAmount, dnsOverride: [String: String], localAddress: String? = nil ) { self.idleReadTimeout = idleReadTimeout self.idleWriteTimeout = idleWriteTimeout + self.connectionCreationTimeout = connectionCreationTimeout self.dnsOverride = dnsOverride self.localAddress = localAddress } @@ -43,8 +48,19 @@ extension RequestOptions { RequestOptions( idleReadTimeout: configuration.timeout.read, idleWriteTimeout: configuration.timeout.write, + connectionCreationTimeout: configuration.timeout.connectionCreationTimeout, dnsOverride: configuration.dnsOverride, localAddress: configuration.localAddress ) } + + /// Applies a per-request stall timeout that, when non-nil, replaces the connect, read, and + /// write timeouts derived from the client configuration. A `nil` value is a no-op so the + /// configured defaults stand. + mutating func apply(stallTimeout: TimeAmount?) { + guard let stallTimeout else { return } + self.idleReadTimeout = stallTimeout + self.idleWriteTimeout = stallTimeout + self.connectionCreationTimeout = stallTimeout + } } diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index dbc40984f..e482ec0ae 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -803,13 +803,14 @@ public final class HTTPClient: Sendable { ) do { + let requestOptions = RequestOptions.fromClientConfiguration(self.configuration) let requestBag = try RequestBag( request: request, eventLoopPreference: eventLoopPreference, task: task, redirectHandler: redirectHandler, - connectionDeadline: .now() + (self.configuration.timeout.connectionCreationTimeout), - requestOptions: .fromClientConfiguration(self.configuration), + connectionDeadline: .now() + requestOptions.connectionCreationTimeout, + requestOptions: requestOptions, delegate: delegate ) diff --git a/Tests/AsyncHTTPClientTests/RequestBagTests.swift b/Tests/AsyncHTTPClientTests/RequestBagTests.swift index 297e81704..401a4a82a 100644 --- a/Tests/AsyncHTTPClientTests/RequestBagTests.swift +++ b/Tests/AsyncHTTPClientTests/RequestBagTests.swift @@ -1171,12 +1171,14 @@ extension RequestOptions { static func forTests( idleReadTimeout: TimeAmount? = nil, idleWriteTimeout: TimeAmount? = nil, + connectionCreationTimeout: TimeAmount = .seconds(10), dnsOverride: [String: String] = [:], localAddress: String? = nil ) -> Self { RequestOptions( idleReadTimeout: idleReadTimeout, idleWriteTimeout: idleWriteTimeout, + connectionCreationTimeout: connectionCreationTimeout, dnsOverride: dnsOverride, localAddress: localAddress )