Merge branch 'main' into ff-new-http-client-api

This commit is contained in:
Fabian Fett
2026-04-24 14:13:21 +02:00
46 changed files with 964 additions and 87 deletions
+29
View File
@@ -38,3 +38,32 @@ jobs:
release-builds:
name: Release builds
uses: apple/swift-nio/.github/workflows/release_builds.yml@main
construct-linkage-test-matrix:
name: Construct linkage matrix
runs-on: ubuntu-latest
outputs:
linkage-test-matrix: '${{ steps.generate-matrix.outputs.linkage-test-matrix }}'
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false
- id: generate-matrix
run: echo "linkage-test-matrix=$(curl -s https://raw.githubusercontent.com/apple/swift-nio/main/scripts/generate_matrix.sh | bash)" >> "$GITHUB_OUTPUT"
env:
MATRIX_LINUX_SETUP_COMMAND: apt-get update -y && apt-get install -yq jq && git config --global --add safe.directory /async-http-client
MATRIX_LINUX_COMMAND: ./scripts/run-linkage-test.sh
MATRIX_LINUX_5_10_ENABLED: false
MATRIX_LINUX_6_0_ENABLED: false
MATRIX_LINUX_6_1_ENABLED: false
MATRIX_LINUX_NIGHTLY_NEXT_ENABLED: false
MATRIX_LINUX_NIGHTLY_MAIN_ENABLED: false
linkage-test:
name: Linkage test
needs: construct-linkage-test-matrix
uses: apple/swift-nio/.github/workflows/swift_test_matrix.yml@main
with:
name: "Linkage test"
matrix_string: '${{ needs.construct-linkage-test-matrix.outputs.linkage-test-matrix }}'
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
timeout-minutes: 1
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Check for Semantic Version label
+7 -2
View File
@@ -56,7 +56,8 @@ let package = Package(
.package(url: "https://github.com/apple/swift-atomics.git", from: "1.0.2"),
.package(url: "https://github.com/apple/swift-algorithms.git", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-distributed-tracing.git", from: "1.3.0"),
.package(url: "https://github.com/apple/swift-configuration.git", from: "1.0.0"),
// Disable all traits to prevent linking Foundation
.package(url: "https://github.com/apple/swift-configuration.git", from: "1.0.0", traits: []),
.package(url: "https://github.com/apple/swift-service-context.git", from: "1.1.0"),
.package(
url: "https://github.com/apple/swift-http-api-proposal.git",
@@ -84,7 +85,11 @@ let package = Package(
.product(name: "NIOSSL", package: "swift-nio-ssl"),
.product(name: "NIOHTTPCompression", package: "swift-nio-extras"),
.product(name: "NIOSOCKS", package: "swift-nio-extras"),
.product(name: "NIOTransportServices", package: "swift-nio-transport-services"),
.product(
name: "NIOTransportServices",
package: "swift-nio-transport-services",
condition: .when(platforms: [.macOS, .iOS, .tvOS, .watchOS, .macCatalyst, .visionOS])
),
.product(name: "Atomics", package: "swift-atomics"),
.product(name: "Algorithms", package: "swift-algorithms"),
.product(name: "Configuration", package: "swift-configuration"),
@@ -17,7 +17,11 @@ import NIOCore
import NIOHTTP1
import Tracing
#if canImport(FoundationEssentials)
import struct FoundationEssentials.URL
#else
import struct Foundation.URL
#endif
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
extension HTTPClient {
@@ -96,6 +100,7 @@ extension HTTPClient {
try HTTPClientRequest.Prepared(
currentRequest,
dnsOverride: configuration.dnsOverride,
localAddress: configuration.localAddress,
tracing: self.configuration.tracing
)
let response = try await {
@@ -18,7 +18,11 @@ import NIOHTTP1
import NIOSSL
import ServiceContextModule
#if canImport(FoundationEssentials)
import struct FoundationEssentials.URL
#else
import struct Foundation.URL
#endif
#if canImport(HTTPAPIs)
import HTTPAPIs
@@ -58,6 +62,7 @@ extension HTTPClientRequest.Prepared {
init(
_ request: HTTPClientRequest,
dnsOverride: [String: String] = [:],
localAddress: String? = nil,
tracing: HTTPClient.TracingConfiguration? = nil
) throws {
guard !request.url.isEmpty, let url = URL(string: request.url) else {
@@ -81,7 +86,12 @@ extension HTTPClientRequest.Prepared {
self.init(
url: url,
poolKey: .init(url: deconstructedURL, tlsConfiguration: request.tlsConfiguration, dnsOverride: dnsOverride),
poolKey: .init(
url: deconstructedURL,
tlsConfiguration: request.tlsConfiguration,
dnsOverride: dnsOverride,
localAddress: request.localAddress ?? localAddress
),
requestFramingMetadata: metadata,
head: .init(
version: .http1_1,
@@ -156,6 +166,7 @@ extension HTTPClientRequest {
newRequest.method = method
newRequest.headers = headers
newRequest.body = body
newRequest.localAddress = self.localAddress
return newRequest
}
}
@@ -12,7 +12,11 @@
//
//===----------------------------------------------------------------------===//
#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
extension HTTPClientRequest {
@@ -57,12 +57,20 @@ public struct HTTPClientRequest: Sendable {
/// Request-specific TLS configuration, defaults to no request-specific TLS configuration.
public var tlsConfiguration: TLSConfiguration?
/// The local IP address to bind this request's connection to.
///
/// When set, overrides ``HTTPClient/Configuration/localAddress`` for this request.
/// The value should be an IP address string (e.g. `"192.168.1.10"` or `"::1"`).
/// Defaults to `nil` (use client configuration default).
public var localAddress: String?
public init(url: String) {
self.url = url
self.method = .GET
self.headers = .init()
self.body = .none
self.tlsConfiguration = nil
self.localAddress = nil
}
}
@@ -15,7 +15,11 @@
import NIOCore
import NIOHTTP1
#if canImport(FoundationEssentials)
import struct FoundationEssentials.URL
#else
import struct Foundation.URL
#endif
/// A representation of an HTTP response for the Swift Concurrency HTTPClient API.
///
+6 -1
View File
@@ -12,9 +12,14 @@
//
//===----------------------------------------------------------------------===//
import Foundation
import NIOHTTP1
#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif
/// Generates base64 encoded username + password for http basic auth.
///
/// - Parameters:
+20 -6
View File
@@ -49,17 +49,20 @@ enum ConnectionPool {
var connectionTarget: ConnectionTarget
private var tlsConfiguration: BestEffortHashableTLSConfiguration?
var serverNameIndicatorOverride: String?
var localAddress: String?
init(
scheme: Scheme,
connectionTarget: ConnectionTarget,
tlsConfiguration: BestEffortHashableTLSConfiguration? = nil,
serverNameIndicatorOverride: String?
serverNameIndicatorOverride: String?,
localAddress: String? = nil
) {
self.scheme = scheme
self.connectionTarget = connectionTarget
self.tlsConfiguration = tlsConfiguration
self.serverNameIndicatorOverride = serverNameIndicatorOverride
self.localAddress = localAddress
}
var description: String {
@@ -75,8 +78,12 @@ enum ConnectionPool {
case .unixSocket(let socketPath):
hostDescription = socketPath
}
return
var result =
"\(self.scheme)://\(hostDescription)\(self.serverNameIndicatorOverride.map { " SNI: \($0)" } ?? "") TLS-hash: \(hash)"
if let addr = self.localAddress {
result += " bind: \(addr)"
}
return result
}
}
}
@@ -97,7 +104,12 @@ extension DeconstructedURL {
}
extension ConnectionPool.Key {
init(url: DeconstructedURL, tlsConfiguration: TLSConfiguration?, dnsOverride: [String: String]) {
init(
url: DeconstructedURL,
tlsConfiguration: TLSConfiguration?,
dnsOverride: [String: String],
localAddress: String? = nil
) {
let (connectionTarget, serverNameIndicatorOverride) = url.applyDNSOverride(dnsOverride)
self.init(
scheme: url.scheme,
@@ -105,15 +117,17 @@ extension ConnectionPool.Key {
tlsConfiguration: tlsConfiguration.map {
BestEffortHashableTLSConfiguration(wrapping: $0)
},
serverNameIndicatorOverride: serverNameIndicatorOverride
serverNameIndicatorOverride: serverNameIndicatorOverride,
localAddress: localAddress
)
}
init(_ request: HTTPClient.Request, dnsOverride: [String: String] = [:]) {
init(_ request: HTTPClient.Request, dnsOverride: [String: String] = [:], localAddress: String? = nil) {
self.init(
url: request.deconstructedURL,
tlsConfiguration: request.tlsConfiguration,
dnsOverride: dnsOverride
dnsOverride: dnsOverride,
localAddress: localAddress
)
}
}
@@ -243,24 +243,32 @@ final class HTTP1ClientChannelHandler: ChannelDuplexHandler {
context.writeAndFlush(self.wrapOutboundOut(.body(part)), promise: writePromise)
case .sendRequestEnd(let trailers, let writePromise, let finalAction):
let writePromise = writePromise ?? context.eventLoop.makePromise(of: Void.self)
// We need to defer succeeding the old request to avoid ordering issues
let writePromise = writePromise ?? context.eventLoop.makePromise(of: Void.self)
// It is fine to bang the request here, as we have just verified with the state machine
// that the request is still ongoing.
// TODO: In the future, we should likely move the request into the state machine to
// prevent diverging state.
let oldRequest = self.request!
switch finalAction {
case .none:
// we must not nil out the request here, as we are still uploading the request
// and therefore still need the reference to it.
break
case .informConnectionIsIdle:
self.request = nil
case .close:
self.request = nil
}
writePromise.futureResult.hop(to: context.eventLoop).assumeIsolated().whenComplete { result in
guard let oldRequest = self.request else {
// in the meantime an error might have happened, which is why this request is
// not reference anymore.
return
}
oldRequest.requestBodyStreamSent()
switch result {
case .success:
// If our final action is not `none`, that means we've already received
// the complete response. As a result, once we've uploaded all the body parts
// we need to tell the pool that the connection is idle or, if we were asked to
// close when we're done, send the close. Either way, we then succeed the request
switch finalAction {
case .none:
// we must not nil out the request here, as we are still uploading the request
@@ -268,13 +276,12 @@ final class HTTP1ClientChannelHandler: ChannelDuplexHandler {
break
case .informConnectionIsIdle:
self.request = nil
self.onConnectionIdle()
case .close:
self.request = nil
context.close(promise: nil)
}
oldRequest.requestBodyStreamSent()
case .failure(let error):
context.close(promise: nil)
@@ -245,14 +245,19 @@ extension HTTPConnectionPool.ConnectionFactory {
promise: EventLoopPromise<NegotiatedProtocol>
) {
precondition(!self.key.scheme.usesTLS, "Unexpected scheme")
return self.makePlainBootstrap(
requester: requester,
connectionID: connectionID,
deadline: deadline,
eventLoop: eventLoop
).connect(target: self.key.connectionTarget).map {
.http1_1($0)
}.cascade(to: promise)
do {
let bootstrap = try self.makePlainBootstrap(
requester: requester,
connectionID: connectionID,
deadline: deadline,
eventLoop: eventLoop
)
bootstrap.connect(target: self.key.connectionTarget).map {
.http1_1($0)
}.cascade(to: promise)
} catch {
promise.fail(error)
}
}
private func makeHTTPProxyChannel<Requester: HTTPConnectionRequester>(
@@ -267,12 +272,18 @@ extension HTTPConnectionPool.ConnectionFactory {
// A proxy connection starts with a plain text connection to the proxy server. After
// the connection has been established with the proxy server, the connection might be
// upgraded to TLS before we send our first request.
let bootstrap = self.makePlainBootstrap(
requester: requester,
connectionID: connectionID,
deadline: deadline,
eventLoop: eventLoop
)
let bootstrap: NIOClientTCPBootstrapProtocol
do {
bootstrap = try self.makePlainBootstrap(
requester: requester,
connectionID: connectionID,
deadline: deadline,
eventLoop: eventLoop
)
} catch {
promise.fail(error)
return
}
bootstrap.connect(host: proxy.host, port: proxy.port).whenComplete { result in
switch result {
case .success(let channel):
@@ -321,12 +332,18 @@ extension HTTPConnectionPool.ConnectionFactory {
// A proxy connection starts with a plain text connection to the proxy server. After
// the connection has been established with the proxy server, the connection might be
// upgraded to TLS before we send our first request.
let bootstrap = self.makePlainBootstrap(
requester: requester,
connectionID: connectionID,
deadline: deadline,
eventLoop: eventLoop
)
let bootstrap: NIOClientTCPBootstrapProtocol
do {
bootstrap = try self.makePlainBootstrap(
requester: requester,
connectionID: connectionID,
deadline: deadline,
eventLoop: eventLoop
)
} catch {
promise.fail(error)
return
}
bootstrap.connect(host: proxy.host, port: proxy.port).whenComplete { result in
switch result {
case .success(let channel):
@@ -421,12 +438,16 @@ extension HTTPConnectionPool.ConnectionFactory {
connectionID: HTTPConnectionPool.Connection.ID,
deadline: NIODeadline,
eventLoop: EventLoop
) -> NIOClientTCPBootstrapProtocol {
) throws -> NIOClientTCPBootstrapProtocol {
if let localAddress = self.key.localAddress, !localAddress.isIPAddress {
throw HTTPClientError.invalidLocalAddress
}
#if canImport(Network)
if #available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *),
let tsBootstrap = NIOTSConnectionBootstrap(validatingGroup: eventLoop)
{
return
var bootstrap =
tsBootstrap
.channelOption(
NIOTSChannelOptions.waitForActivity,
@@ -448,14 +469,32 @@ extension HTTPConnectionPool.ConnectionFactory {
return channel.eventLoop.makeFailedFuture(error)
}
}
if let localAddress = self.key.localAddress {
bootstrap = bootstrap.configureNWParameters { params in
params.requiredLocalEndpoint = NWEndpoint.hostPort(
host: NWEndpoint.Host(localAddress),
port: .any
)
}
}
return bootstrap
}
#endif
if let nioBootstrap = ClientBootstrap(validatingGroup: eventLoop) {
return
var bootstrap =
nioBootstrap
.connectTimeout(deadline - NIODeadline.now())
.enableMPTCP(clientConfiguration.enableMultipath)
if let localAddress = self.key.localAddress {
do {
let socketAddress = try SocketAddress(ipAddress: localAddress, port: 0)
bootstrap = bootstrap.bind(to: socketAddress)
} catch {
throw HTTPClientError.invalidLocalAddress
}
}
return bootstrap
}
preconditionFailure("No matching bootstrap found")
@@ -523,6 +562,10 @@ extension HTTPConnectionPool.ConnectionFactory {
eventLoop: EventLoop,
logger: Logger
) -> EventLoopFuture<NIOClientTCPBootstrapProtocol> {
if let localAddress = self.key.localAddress, !localAddress.isIPAddress {
return eventLoop.makeFailedFuture(HTTPClientError.invalidLocalAddress)
}
var tlsConfig = self.tlsConfiguration
switch self.clientConfiguration.httpVersion.configuration {
case .automatic:
@@ -538,13 +581,14 @@ extension HTTPConnectionPool.ConnectionFactory {
#if canImport(Network)
if #available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *), eventLoop is QoSEventLoop {
// create NIOClientTCPBootstrap with NIOTS TLS provider
let localAddr = self.key.localAddress
let bootstrapFuture = tlsConfig.getNWProtocolTLSOptions(
on: eventLoop,
serverNameIndicatorOverride: key.serverNameIndicatorOverride
).map {
options -> NIOClientTCPBootstrapProtocol in
NIOTSConnectionBootstrap(group: eventLoop) // validated above
var bootstrap = NIOTSConnectionBootstrap(group: eventLoop) // validated above
.channelOption(
NIOTSChannelOptions.waitForActivity,
value: self.clientConfiguration.networkFrameworkWaitForConnectivity
@@ -569,7 +613,16 @@ extension HTTPConnectionPool.ConnectionFactory {
} catch {
return channel.eventLoop.makeFailedFuture(error)
}
} as NIOClientTCPBootstrapProtocol
}
if let localAddress = localAddr {
bootstrap = bootstrap.configureNWParameters { params in
params.requiredLocalEndpoint = NWEndpoint.hostPort(
host: NWEndpoint.Host(localAddress),
port: .any
)
}
}
return bootstrap as NIOClientTCPBootstrapProtocol
}
return bootstrapFuture
}
@@ -581,10 +634,20 @@ extension HTTPConnectionPool.ConnectionFactory {
logger: logger
)
return eventLoop.submit {
ClientBootstrap(group: eventLoop)
return eventLoop.submit { [key] () throws -> NIOClientTCPBootstrapProtocol in
var bootstrap = ClientBootstrap(group: eventLoop)
.connectTimeout(deadline - NIODeadline.now())
.enableMPTCP(clientConfiguration.enableMultipath)
if let localAddress = key.localAddress {
do {
let socketAddress = try SocketAddress(ipAddress: localAddress, port: 0)
bootstrap = bootstrap.bind(to: socketAddress)
} catch {
throw HTTPClientError.invalidLocalAddress
}
}
return
bootstrap
.channelInitializer { channel in
sslContextFuture.flatMap { sslContext -> EventLoopFuture<Void> in
do {
@@ -21,15 +21,20 @@ struct RequestOptions {
var idleWriteTimeout: TimeAmount?
/// DNS overrides.
var dnsOverride: [String: String]
/// The local IP address to bind outgoing connections to. This is typically used on multi-NIC
/// systems where we want to control where traffic goes.
var localAddress: String?
init(
idleReadTimeout: TimeAmount?,
idleWriteTimeout: TimeAmount?,
dnsOverride: [String: String]
dnsOverride: [String: String],
localAddress: String? = nil
) {
self.idleReadTimeout = idleReadTimeout
self.idleWriteTimeout = idleWriteTimeout
self.dnsOverride = dnsOverride
self.localAddress = localAddress
}
}
@@ -38,7 +43,8 @@ extension RequestOptions {
RequestOptions(
idleReadTimeout: configuration.timeout.read,
idleWriteTimeout: configuration.timeout.write,
dnsOverride: configuration.dnsOverride
dnsOverride: configuration.dnsOverride,
localAddress: configuration.localAddress
)
}
}
@@ -12,7 +12,11 @@
//
//===----------------------------------------------------------------------===//
#if canImport(FoundationEssentials)
import struct FoundationEssentials.URL
#else
import struct Foundation.URL
#endif
struct DeconstructedURL {
var scheme: Scheme
@@ -17,7 +17,11 @@ import NIOCore
import NIOHTTP1
import NIOPosix
#if canImport(FoundationEssentials)
import struct FoundationEssentials.URL
#else
import struct Foundation.URL
#endif
/// Handles a streaming download to a given file path, allowing headers and progress to be reported.
public final class FileDownloadDelegate: HTTPClientResponseDelegate {
@@ -15,7 +15,11 @@
// Extensions which provide better ergonomics when using Foundation types,
// or by using Foundation APIs.
#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif
extension HTTPClient.Cookie {
/// The cookie's expiration date.
@@ -73,3 +77,66 @@ extension HTTPClient.Body {
self.bytes(data)
}
}
extension StringProtocol {
func addingPercentEncodingAllowingURLHost() -> String {
guard !self.isEmpty else { return String(self) }
let percent = UInt8(ascii: "%")
let utf8Buffer = self.utf8
let maxLength = utf8Buffer.count * 3
return withUnsafeTemporaryAllocation(of: UInt8.self, capacity: maxLength) { outputBuffer in
var i = 0
for byte in utf8Buffer {
if byte.isURLHostAllowed {
outputBuffer[i] = byte
i += 1
} else {
outputBuffer[i] = percent
outputBuffer[i + 1] = hexToAscii(byte >> 4)
outputBuffer[i + 2] = hexToAscii(byte & 0xF)
i += 3
}
}
return String(decoding: outputBuffer[..<i], as: UTF8.self)
}
}
}
private func hexToAscii(_ hex: UInt8) -> UInt8 {
switch hex {
case 0x0: return UInt8(ascii: "0")
case 0x1: return UInt8(ascii: "1")
case 0x2: return UInt8(ascii: "2")
case 0x3: return UInt8(ascii: "3")
case 0x4: return UInt8(ascii: "4")
case 0x5: return UInt8(ascii: "5")
case 0x6: return UInt8(ascii: "6")
case 0x7: return UInt8(ascii: "7")
case 0x8: return UInt8(ascii: "8")
case 0x9: return UInt8(ascii: "9")
case 0xA: return UInt8(ascii: "A")
case 0xB: return UInt8(ascii: "B")
case 0xC: return UInt8(ascii: "C")
case 0xD: return UInt8(ascii: "D")
case 0xE: return UInt8(ascii: "E")
case 0xF: return UInt8(ascii: "F")
default: fatalError("Invalid hex digit: \(hex)")
}
}
extension UInt8 {
fileprivate var isURLHostAllowed: Bool {
switch self {
case UInt8(ascii: "0")...UInt8(ascii: "9"),
UInt8(ascii: "A")...UInt8(ascii: "Z"),
UInt8(ascii: "a")...UInt8(ascii: "z"),
UInt8(ascii: "!"), UInt8(ascii: "$"), UInt8(ascii: "&"), UInt8(ascii: "'"),
UInt8(ascii: "("), UInt8(ascii: ")"), UInt8(ascii: "*"), UInt8(ascii: "+"),
UInt8(ascii: ","), UInt8(ascii: "-"), UInt8(ascii: "."), UInt8(ascii: ";"),
UInt8(ascii: "="), UInt8(ascii: "_"), UInt8(ascii: "~"):
return true
default: return false
}
}
}
+32 -2
View File
@@ -13,7 +13,7 @@
//===----------------------------------------------------------------------===//
import Atomics
import Foundation
import Dispatch
import Logging
import NIOConcurrencyHelpers
import NIOCore
@@ -22,9 +22,18 @@ import NIOHTTPCompression
import NIOPosix
import NIOSSL
import NIOTLS
import NIOTransportServices
import Tracing
#if canImport(Network)
import NIOTransportServices
#endif
#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif
extension Logger {
private func requestInfo(_ request: HTTPClient.Request) -> Logger.Metadata.Value {
"\(request.method) \(request.url)"
@@ -895,6 +904,20 @@ public final class HTTPClient: Sendable {
/// By default, don't use it
public var enableMultipath: Bool
/// The local IP address to bind outgoing connections to.
///
/// When set, all outgoing connections will bind to this address before connecting.
/// The value should be an IP address string (e.g. `"192.168.1.10"` or `"::1"`).
/// Port 0 (OS-assigned ephemeral port) is always used.
///
/// This is most commonly used on multi-NIC systems where you want traffic to take a
/// specific network path which is not the choice the routing table would make by
/// default.
///
/// This can be overridden on a per-request basis using ``HTTPClientRequest/localAddress``.
/// Defaults to `nil` (OS default interface selection).
public var localAddress: String?
/// A method with access to the HTTP/1 connection channel that is called when creating the connection.
public var http1_1ConnectionDebugInitializer: (@Sendable (Channel) -> EventLoopFuture<Void>)?
@@ -925,6 +948,7 @@ public final class HTTPClient: Sendable {
self.httpVersion = .automatic
self.networkFrameworkWaitForConnectivity = true
self.enableMultipath = false
self.localAddress = nil
}
public init(
@@ -1452,6 +1476,7 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible {
case invalidRedirectConfiguration
case invalidHTTPVersionConfiguration
case invalidDNSOverridesConfiguration
case invalidLocalAddress
case internalStateFailure(file: String, line: UInt)
}
@@ -1545,6 +1570,8 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible {
case .invalidDNSOverridesConfiguration:
return
"The DNS overrides specified in the configuration are not valid. Please specify in the format hostname1:ip1,hostname2:ip2"
case .invalidLocalAddress:
return "Invalid local address"
case .internalStateFailure(let file, let line):
return
"An internal state failure has occurred (File: \(file), line: \(line)). Please open an issue with a reproducer if possible"
@@ -1648,6 +1675,9 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible {
/// The DNS overrides specified in the configuration are not valid.
public static let invalidDNSOverridesConfiguration = HTTPClientError(code: .invalidDNSOverridesConfiguration)
/// The local address specified is not a valid IP address.
public static let invalidLocalAddress = HTTPClientError(code: .invalidLocalAddress)
/// A state machine has reached an unsupported state, that wasn't considered when implementing.
public static func internalStateFailure(file: String = #fileID, line: UInt = #line) -> HTTPClientError {
HTTPClientError(code: .internalStateFailure(file: file, line: line))
+8 -3
View File
@@ -13,7 +13,6 @@
//===----------------------------------------------------------------------===//
import Algorithms
import Foundation
import Logging
import NIOConcurrencyHelpers
import NIOCore
@@ -22,6 +21,12 @@ import NIOPosix
import NIOSSL
import Tracing
#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif
extension HTTPClient {
/// A request body.
public struct Body: Sendable {
@@ -880,7 +885,7 @@ extension URL {
/// - socketPath: The path to the unix domain socket to connect to.
/// - uri: The URI path and query that will be sent to the server.
public init?(httpURLWithSocketPath socketPath: String, uri: String = "/") {
guard let host = socketPath.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) else { return nil }
let host = socketPath.addingPercentEncodingAllowingURLHost()
var urlString: String
if uri.hasPrefix("/") {
urlString = "http+unix://\(host)\(uri)"
@@ -895,7 +900,7 @@ extension URL {
/// - socketPath: The path to the unix domain socket to connect to.
/// - uri: The URI path and query that will be sent to the server.
public init?(httpsURLWithSocketPath socketPath: String, uri: String = "/") {
guard let host = socketPath.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) else { return nil }
let host = socketPath.addingPercentEncodingAllowingURLHost()
var urlString: String
if uri.hasPrefix("/") {
urlString = "https+unix://\(host)\(uri)"
@@ -14,10 +14,10 @@
import NIOCore
import NIOHTTP1
import NIOTransportServices
#if canImport(Network)
import Network
import NIOTransportServices
#endif
extension HTTPClient {
@@ -14,7 +14,12 @@
#if canImport(Network)
#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif
import Dispatch
import Network
import NIOCore
import NIOSSL
@@ -14,7 +14,11 @@
import NIOHTTP1
#if canImport(FoundationEssentials)
import struct FoundationEssentials.URL
#else
import struct Foundation.URL
#endif
typealias RedirectMode = HTTPClient.Configuration.RedirectConfiguration.Mode
@@ -15,7 +15,11 @@
import NIOCore
import NIOHTTP1
#if canImport(FoundationEssentials)
import struct FoundationEssentials.URL
#else
import struct Foundation.URL
#endif
extension HTTPClient {
/// The maximum body size allowed, before a redirect response is cancelled. 3KB.
@@ -22,7 +22,7 @@ import Tracing
extension RequestBag.LoopBoundState {
/// Starts the "overall" Span that encompases the beginning of a request until receipt of the head part of the response.
mutating func startRequestSpan<T>(tracer: T?) {
mutating func startRequestSpan(tracer: (any Sendable)?) {
guard #available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *),
let tracer = tracer as! (any Tracer)?
else {
+5 -1
View File
@@ -96,7 +96,11 @@ final class RequestBag<Delegate: HTTPClientResponseDelegate & Sendable>: Sendabl
requestOptions: RequestOptions,
delegate: Delegate
) throws {
self.poolKey = .init(request, dnsOverride: requestOptions.dnsOverride)
self.poolKey = .init(
request,
dnsOverride: requestOptions.dnsOverride,
localAddress: requestOptions.localAddress
)
self.eventLoopPreference = eventLoopPreference
self.task = task
@@ -1108,6 +1108,136 @@ final class AsyncAwaitEndToEndTests: XCTestCase {
XCTAssertEqual(responseGet.history[1].request.method, .GET, "Redirected request should remain GET")
}
}
// MARK: - Integration tests: local address binding
func testLocalAddressBinding_configLevel() async throws {
// On Linux, 127.0.0.0/8 all route to loopback, so we can use a
// non-default address to prove the bind actually happened.
#if os(Linux)
let localAddress = "127.0.0.127"
#else
let localAddress = "127.0.0.1"
#endif
let bin = HTTPBin(.http1_1(ssl: false))
defer { XCTAssertNoThrow(try bin.shutdown()) }
var config = HTTPClient.Configuration()
.enableFastFailureModeForTesting()
config.localAddress = localAddress
let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config)
defer { XCTAssertNoThrow(try client.syncShutdown()) }
let request = HTTPClientRequest(url: "http://127.0.0.1:\(bin.port)/echo-client-ip")
let response = try await client.execute(request, deadline: .now() + .seconds(10))
XCTAssertEqual(response.status, .ok)
var body = try await response.body.collect(upTo: 1024)
let requestInfo = try body.readJSONDecodable(RequestInfo.self, length: body.readableBytes)
XCTAssertEqual(requestInfo?.data, localAddress)
}
func testLocalAddressBinding_perRequest() async throws {
#if os(Linux)
let localAddress = "127.0.0.127"
#else
let localAddress = "127.0.0.1"
#endif
let bin = HTTPBin(.http1_1(ssl: false))
defer { XCTAssertNoThrow(try bin.shutdown()) }
let config = HTTPClient.Configuration()
.enableFastFailureModeForTesting()
let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config)
defer { XCTAssertNoThrow(try client.syncShutdown()) }
var request = HTTPClientRequest(url: "http://127.0.0.1:\(bin.port)/echo-client-ip")
request.localAddress = localAddress
let response = try await client.execute(request, deadline: .now() + .seconds(10))
XCTAssertEqual(response.status, .ok)
var body = try await response.body.collect(upTo: 1024)
let requestInfo = try body.readJSONDecodable(RequestInfo.self, length: body.readableBytes)
XCTAssertEqual(requestInfo?.data, localAddress)
}
func testLocalAddressBinding_perRequestOverridesConfig() async throws {
#if os(Linux)
let localAddress = "127.0.0.127"
#else
let localAddress = "127.0.0.1"
#endif
let bin = HTTPBin(.http1_1(ssl: false))
defer { XCTAssertNoThrow(try bin.shutdown()) }
var config = HTTPClient.Configuration()
.enableFastFailureModeForTesting()
config.localAddress = "127.0.0.1"
let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config)
defer { XCTAssertNoThrow(try client.syncShutdown()) }
var request = HTTPClientRequest(url: "http://127.0.0.1:\(bin.port)/echo-client-ip")
request.localAddress = localAddress
let response = try await client.execute(request, deadline: .now() + .seconds(10))
XCTAssertEqual(response.status, .ok)
var body = try await response.body.collect(upTo: 1024)
let requestInfo = try body.readJSONDecodable(RequestInfo.self, length: body.readableBytes)
XCTAssertEqual(requestInfo?.data, localAddress)
}
func testLocalAddressBinding_invalidAddress() async throws {
var config = HTTPClient.Configuration()
.enableFastFailureModeForTesting()
config.localAddress = "not-a-valid-ip"
let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config)
defer { XCTAssertNoThrow(try client.syncShutdown()) }
let request = HTTPClientRequest(url: "http://127.0.0.1/ok")
do {
_ = try await client.execute(request, deadline: .now() + .seconds(10))
XCTFail("Expected error to be thrown")
} catch {
XCTAssertEqual(error as? HTTPClientError, .invalidLocalAddress)
}
}
func testLocalAddressBinding_withTLS() async throws {
#if os(Linux)
let localAddress = "127.0.0.127"
#else
let localAddress = "127.0.0.1"
#endif
let bin = HTTPBin(.http2(compress: false))
defer { XCTAssertNoThrow(try bin.shutdown()) }
var config = HTTPClient.Configuration()
.enableFastFailureModeForTesting()
config.tlsConfiguration = .clientDefault
config.tlsConfiguration?.certificateVerification = .none
config.localAddress = localAddress
let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config)
defer { XCTAssertNoThrow(try client.syncShutdown()) }
let request = HTTPClientRequest(url: "https://127.0.0.1:\(bin.port)/echo-client-ip")
let response = try await client.execute(request, deadline: .now() + .seconds(10))
XCTAssertEqual(response.status, .ok)
var body = try await response.body.collect(upTo: 1024)
let requestInfo = try body.readJSONDecodable(RequestInfo.self, length: body.readableBytes)
XCTAssertEqual(requestInfo?.data, localAddress)
}
}
struct AnySendableSequence<Element>: @unchecked Sendable {
@@ -23,11 +23,11 @@ import NIOHTTPCompression
import NIOPosix
import NIOSSL
import NIOTestUtils
import NIOTransportServices
import XCTest
#if canImport(Network)
import Network
import NIOTransportServices
#endif
final class ConnectionPoolSizeConfigValueIsRespectedTests: XCTestCaseHTTPClientTestsBaseClass {
@@ -0,0 +1,51 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the AsyncHTTPClient open source project
//
// Copyright (c) 2026 Apple Inc. and the AsyncHTTPClient project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//
import Foundation
import Testing
@testable import AsyncHTTPClient
@Suite
struct FoundationExtensionTests {
@Test(arguments: [
// Format: (input, expected)
("localhost", "localhost"), // Alphanumerics (No encoding needed)
("example.com", "example.com"), // Domain with unreserved dot (No encoding needed)
("user@email.com", "user%40email.com"), // '@' is not allowed in host (Should be encoded to %40)
("!$&'()*+,;=[]", "!$&\'()*+,;=%5B%5D"), // Sub-delimiters and brackets (Allowed in host, NO encoding)
("~_.-", "~_.-"), // Unreserved punctuation (Allowed in host, NO encoding)
("café", "caf%C3%A9"), // Non-ASCII character (Should be encoded)
("👨‍💻 swift", "%F0%9F%91%A8%E2%80%8D%F0%9F%92%BB%20swift"), // Emoji and space (Space to %20, Emoji encoded)
("", ""), // Empty string
("100% coverage", "100%25%20coverage"), // '%' symbol itself (Must be encoded to %25)
("sub.domain_test~1.com", "sub.domain_test~1.com"), // Mix of allowed characters
// Invalid host chars like '/', '?', and '#' (Should be encoded), '=' is a valid sub-delimiter
("path/to/api?query=1#frag", "path%2Fto%2Fapi%3Fquery=1%23frag"),
])
func addingPercentEncodingAllowingURLHost(input: String, expected: String) {
let foundationResult = input.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)
let customResult = input.addingPercentEncodingAllowingURLHost()
#expect(
customResult == expected,
"Encoding mismatch for input: '\(input)'. Expected '\(String(describing: expected))', got '\(String(describing: customResult))'"
)
#expect(
customResult == foundationResult,
"Result did not match Foundation: '\(input)'. Expected '\(String(describing: foundationResult))', got '\(String(describing: customResult))'"
)
}
}
@@ -947,6 +947,121 @@ class HTTP1ClientChannelHandlerTests: XCTestCase {
)
}
func testDemandResponseBodyStreamAfterEarlyResponseDoesNotCrash() async throws {
// This test reproduces a crash where `demandMoreResponseBodyParts()` was called on the
// state machine after it had already transitioned to `.idle`. The scenario is:
//
// 1. A streaming POST request is in progress
// 2. The full response (head + end) arrives before the request body is finished
// 3. The response end is forwarded with `finalAction: .none` (body still uploading)
// 4. The request body stream finishes -> `requestStreamFinished` -> state machine
// transitions to `.idle` and returns `.sendRequestEnd(.informConnectionIsIdle)`
// 5. The `.sendRequestEnd` handler writes `.end` to the channel and registers a
// callback on the write promise
// 6. Before the write callback fires (write hasn't completed yet),
// `demandResponseBodyStream` is called (from a delegate on another event loop)
// 7. Without the fix, `self.request` was still set (only nilled in the write
// callback), so the guard passed and `demandMoreResponseBodyParts()` hit
// `fatalError("Invalid state: idle")`
//
// The fix nils out `self.request` synchronously in `.sendRequestEnd` (before the
// write callback), so the guard in `demandResponseBodyStream0` fails and returns early.
final class DelayEndHandler: ChannelOutboundHandler {
typealias OutboundIn = HTTPClientRequestPart
typealias OutboundOut = HTTPClientRequestPart
private(set) var endPromise: EventLoopPromise<Void>?
func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise<Void>?) {
if case .end = self.unwrapOutboundIn(data) {
self.endPromise = promise
context.write(data, promise: nil)
} else {
context.write(data, promise: promise)
}
}
}
let eventLoop = EmbeddedEventLoop()
let delayEndHandler = DelayEndHandler()
let handler = HTTP1ClientChannelHandler(
eventLoop: eventLoop,
backgroundLogger: Logger(label: "no-op", factory: SwiftLogNoOpLogHandler.init),
connectionIdLoggerMetadata: "test connection"
)
var connectionIsIdle = false
handler.onConnectionIdle = { connectionIsIdle = true }
let channel = EmbeddedChannel(handlers: [delayEndHandler, handler], loop: eventLoop)
XCTAssertNoThrow(try channel.connect(to: .init(ipAddress: "127.0.0.1", port: 80)).wait())
let request = MockHTTPExecutableRequest(
head: .init(version: .http1_1, method: .POST, uri: "http://localhost/"),
framingMetadata: RequestFramingMetadata(connectionClose: false, body: .stream),
raiseErrorIfUnimplementedMethodIsCalled: false
)
let executor = handler.requestExecutor
// When the body stream is resumed, write one part but do NOT finish the stream yet.
request.resumeRequestBodyStreamCallback = {
executor.writeRequestBodyPart(.byteBuffer(.init(string: "Hello")), request: request, promise: nil)
}
// Start the request
channel.write(request, promise: nil)
// Verify request head was sent
XCTAssertEqual(try channel.readOutbound(as: HTTPClientRequestPart.self), .head(request.requestHead))
// Verify body part was sent
XCTAssertEqual(
try channel.readOutbound(as: HTTPClientRequestPart.self),
.body(.byteBuffer(.init(string: "Hello")))
)
// Now send the full response while the request body stream is still open.
// This causes forwardResponseEnd with finalAction: .none (body not done yet).
XCTAssertNoThrow(try channel.writeInbound(HTTPClientResponsePart.head(.init(version: .http1_1, status: .ok))))
// Issue a read to advance the response stream state so it accepts the end properly.
channel.read()
XCTAssertNoThrow(try channel.writeInbound(HTTPClientResponsePart.end(nil)))
// Finish the request body stream. This transitions the state machine to `.idle`
// and writes `.end` to the channel. The DelayEndHandler intercepts the `.end`
// write and holds the promise, preventing the write callback from firing.
executor.finishRequestBodyStream(trailers: nil, request: request, promise: nil)
// Verify the .end was written through to the channel
XCTAssertEqual(try channel.readOutbound(as: HTTPClientRequestPart.self), .end(nil))
// At this point:
// - The state machine has transitioned to `.idle`
// - The write promise has NOT been fulfilled (held by DelayEndHandler)
// - In old code: self.request is still set (only nilled in the write callback)
// - In fixed code: self.request is already nil (nilled synchronously)
// Now call demandResponseBodyStream, simulating a delegate on a different event
// loop calling it after receiving the response end but before the write completes.
// Without the fix, self.request is still set, the guard passes, and
// state.demandMoreResponseBodyParts() crashes with "Invalid state: idle".
// With the fix, self.request was already nilled, the guard fails, and this is a no-op.
executor.demandResponseBodyStream(request)
// Complete the delayed write to clean up properly.
delayEndHandler.endPromise?.succeed(())
eventLoop.run()
XCTAssertTrue(connectionIsIdle)
XCTAssertEqual(
request.events.map(\.kind),
[
.willExecuteRequest, .requestHeadSent, .resumeRequestBodyStream,
.receiveResponseHead, .receiveResponseEnd, .requestBodySent,
]
)
}
func testDefaultMaxBufferSize() {
if MemoryLayout<Int>.size == 8 {
XCTAssertEqual(ResponseAccumulator.maxByteBufferSize, Int(UInt32.max))
@@ -24,11 +24,11 @@ import NIOHTTPCompression
import NIOPosix
import NIOSSL
import NIOTestUtils
import NIOTransportServices
import XCTest
#if canImport(Network)
import Network
import NIOTransportServices
#endif
class XCTestCaseHTTPClientTestsBaseClass: XCTestCase {
@@ -16,13 +16,13 @@ import NIOConcurrencyHelpers
import NIOCore
import NIOPosix
import NIOSSL
import NIOTransportServices
import XCTest
@testable import AsyncHTTPClient
#if canImport(Network)
import Network
import NIOTransportServices
#endif
class HTTPClientNIOTSTests: XCTestCase {
@@ -27,11 +27,15 @@ import NIOHTTPCompression
import NIOPosix
import NIOSSL
import NIOTLS
import NIOTransportServices
import XCTest
@testable import AsyncHTTPClient
#if canImport(Network)
import Network
import NIOTransportServices
#endif
#if canImport(xlocale)
import xlocale
#elseif canImport(locale_h)
@@ -1047,6 +1051,13 @@ internal final class HTTPBinHandler: ChannelInboundHandler {
}
self.resps.append(HTTPResponseBuilder(status: .ok))
return
case "/echo-client-ip":
var builder = HTTPResponseBuilder(status: .ok)
let clientIP = context.channel.remoteAddress?.ipAddress ?? "unknown"
let buf = context.channel.allocator.buffer(string: clientIP)
builder.add(buf)
self.resps.append(builder)
return
case "/echohostheader":
var builder = HTTPResponseBuilder(status: .ok)
let hostValue = req.headers["Host"].first ?? ""
@@ -26,11 +26,11 @@ import NIOHTTPCompression
import NIOPosix
import NIOSSL
import NIOTestUtils
import NIOTransportServices
import XCTest
#if canImport(Network)
import Network
import NIOTransportServices
#endif
final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass {
@@ -4644,6 +4644,33 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass {
)
}
}
func testLocalAddressBinding_configLevel() throws {
// On Linux, 127.0.0.0/8 all route to loopback, so we can use a
// non-default address to prove the bind actually happened.
#if os(Linux)
let localAddress = "127.0.0.127"
#else
let localAddress = "127.0.0.1"
#endif
let bin = HTTPBin(.http1_1(ssl: false))
defer { XCTAssertNoThrow(try bin.shutdown()) }
var config = HTTPClient.Configuration()
.enableFastFailureModeForTesting()
config.localAddress = localAddress
let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config)
defer { XCTAssertNoThrow(try client.syncShutdown()) }
let response = try client.get(url: "http://127.0.0.1:\(bin.port)/echo-client-ip").wait()
XCTAssertEqual(response.status, .ok)
let bytes = response.body.flatMap { $0.getData(at: 0, length: $0.readableBytes) }
let data = try JSONDecoder().decode(RequestInfo.self, from: bytes!)
XCTAssertEqual(data.data, localAddress)
}
}
final class CountingDebugInitializerUtil: Sendable {
@@ -24,7 +24,6 @@ import NIOHTTPCompression
import NIOPosix
import NIOSSL
import NIOTestUtils
import NIOTransportServices
import Tracing
import XCTest
@@ -32,6 +31,7 @@ import XCTest
#if canImport(Network)
import Network
import NIOTransportServices
#endif
private func makeTracedHTTPClient(tracer: InMemoryTracer) -> HTTPClient {
@@ -25,12 +25,12 @@ import NIOHTTPCompression
import NIOPosix
import NIOSSL
import NIOTestUtils
import NIOTransportServices
import Tracing
import XCTest
#if canImport(Network)
import Network
import NIOTransportServices
#endif
private func makeTracedHTTPClient(tracer: InMemoryTracer) -> HTTPClient {
@@ -23,11 +23,11 @@ import NIOHTTPCompression
import NIOPosix
import NIOSSL
import NIOTestUtils
import NIOTransportServices
import XCTest
#if canImport(Network)
import Network
import NIOTransportServices
#endif
final class TestIdleTimeoutNoReuse: XCTestCaseHTTPClientTestsBaseClass {
@@ -0,0 +1,133 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the AsyncHTTPClient open source project
//
// Copyright (c) 2026 Apple Inc. and the AsyncHTTPClient project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//
import NIOHTTP1
import Testing
import struct Foundation.URL
@testable import AsyncHTTPClient
struct LocalAddressOverrideTests {
// MARK: - Pool Key with localAddress
@Test func poolKeysWithDifferentLocalAddressesAreNotEqual() {
let key1 = ConnectionPool.Key(
scheme: .https,
connectionTarget: .domain(name: "example.com", port: 443),
serverNameIndicatorOverride: nil,
localAddress: "192.168.1.10"
)
let key2 = ConnectionPool.Key(
scheme: .https,
connectionTarget: .domain(name: "example.com", port: 443),
serverNameIndicatorOverride: nil,
localAddress: "10.0.0.1"
)
let keyNil = ConnectionPool.Key(
scheme: .https,
connectionTarget: .domain(name: "example.com", port: 443),
serverNameIndicatorOverride: nil,
localAddress: nil
)
#expect(key1 != key2)
#expect(key1 != keyNil)
#expect(key2 != keyNil)
}
@Test func poolKeysWithSameLocalAddressAreEqual() {
let key1 = ConnectionPool.Key(
scheme: .https,
connectionTarget: .domain(name: "example.com", port: 443),
serverNameIndicatorOverride: nil,
localAddress: "192.168.1.10"
)
let key2 = ConnectionPool.Key(
scheme: .https,
connectionTarget: .domain(name: "example.com", port: 443),
serverNameIndicatorOverride: nil,
localAddress: "192.168.1.10"
)
#expect(key1 == key2)
}
@Test func poolKeyWithNilLocalAddressMatchesDefault() {
let key1 = ConnectionPool.Key(
scheme: .https,
connectionTarget: .domain(name: "example.com", port: 443),
serverNameIndicatorOverride: nil
)
let key2 = ConnectionPool.Key(
scheme: .https,
connectionTarget: .domain(name: "example.com", port: 443),
serverNameIndicatorOverride: nil,
localAddress: nil
)
#expect(key1 == key2)
}
// MARK: - Per-request localAddress override
@Test func perRequestLocalAddressOverridesConfig() throws {
var request = HTTPClientRequest(url: "https://example.com/get")
request.localAddress = "10.0.0.1"
let prepared = try HTTPClientRequest.Prepared(
request,
localAddress: "192.168.1.10"
)
#expect(prepared.poolKey.localAddress == "10.0.0.1")
}
@Test func configLocalAddressUsedWhenRequestHasNone() throws {
let request = HTTPClientRequest(url: "https://example.com/get")
let prepared = try HTTPClientRequest.Prepared(
request,
localAddress: "192.168.1.10"
)
#expect(prepared.poolKey.localAddress == "192.168.1.10")
}
@Test func noLocalAddressWhenNeitherSet() throws {
let request = HTTPClientRequest(url: "https://example.com/get")
let prepared = try HTTPClientRequest.Prepared(request)
#expect(prepared.poolKey.localAddress == nil)
}
// MARK: - Redirect preserves localAddress
@Test func redirectPreservesLocalAddress() {
var request = HTTPClientRequest(url: "https://example.com/redirect/301")
request.localAddress = "192.168.1.10"
let redirected = request.followingRedirect(
from: URL(string: "https://example.com/redirect/301")!,
to: URL(string: "https://other.com/ok")!,
status: .movedPermanently,
config: .init(
max: 5,
allowCycles: false,
retainHTTPMethodAndBodyOn301: false,
retainHTTPMethodAndBodyOn302: false
)
)
#expect(redirected.localAddress == "192.168.1.10")
}
}
@@ -23,11 +23,11 @@ import NIOHTTPCompression
import NIOPosix
import NIOSSL
import NIOTestUtils
import NIOTransportServices
import XCTest
#if canImport(Network)
import Network
import NIOTransportServices
#endif
final class NoBytesSentOverBodyLimitTests: XCTestCaseHTTPClientTestsBaseClass {
@@ -23,11 +23,11 @@ import NIOHTTPCompression
import NIOPosix
import NIOSSL
import NIOTestUtils
import NIOTransportServices
import XCTest
#if canImport(Network)
import Network
import NIOTransportServices
#endif
final class RacePoolIdleConnectionsAndGetTests: XCTestCaseHTTPClientTestsBaseClass {
@@ -1013,6 +1013,40 @@ final class RequestBagTests: XCTestCase {
}
XCTAssertTrue(isKnownUniquelyReferenced(&leakDetector))
}
func testRequestBagPassesLocalAddressToPoolKey() throws {
let request = try HTTPClient.Request(url: "https://example.com/get")
let eventLoop = EmbeddedEventLoop()
defer { XCTAssertNoThrow(try eventLoop.syncShutdownGracefully()) }
let task = HTTPClient.Task<HTTPClient.Response>(eventLoop: eventLoop, logger: .init(label: "test"))
let requestBag = try RequestBag(
request: request,
eventLoopPreference: .indifferent,
task: task,
redirectHandler: nil,
connectionDeadline: .distantFuture,
requestOptions: .forTests(localAddress: "10.0.0.1"),
delegate: ResponseAccumulator(request: request)
)
XCTAssertEqual(requestBag.poolKey.localAddress, "10.0.0.1")
}
func testRequestBagPoolKeyNilLocalAddressByDefault() throws {
let request = try HTTPClient.Request(url: "https://example.com/get")
let eventLoop = EmbeddedEventLoop()
defer { XCTAssertNoThrow(try eventLoop.syncShutdownGracefully()) }
let task = HTTPClient.Task<HTTPClient.Response>(eventLoop: eventLoop, logger: .init(label: "test"))
let requestBag = try RequestBag(
request: request,
eventLoopPreference: .indifferent,
task: task,
redirectHandler: nil,
connectionDeadline: .distantFuture,
requestOptions: .forTests(),
delegate: ResponseAccumulator(request: request)
)
XCTAssertNil(requestBag.poolKey.localAddress)
}
}
extension HTTPClient.Task {
@@ -1137,12 +1171,14 @@ extension RequestOptions {
static func forTests(
idleReadTimeout: TimeAmount? = nil,
idleWriteTimeout: TimeAmount? = nil,
dnsOverride: [String: String] = [:]
dnsOverride: [String: String] = [:],
localAddress: String? = nil
) -> Self {
RequestOptions(
idleReadTimeout: idleReadTimeout,
idleWriteTimeout: idleWriteTimeout,
dnsOverride: dnsOverride
dnsOverride: dnsOverride,
localAddress: localAddress
)
}
}
@@ -1,12 +1,12 @@
-----BEGIN CERTIFICATE-----
MIIBwTCCAUigAwIBAgIUX7f9BABxGdAqG5EvLpQScFt9lOkwCgYIKoZIzj0EAwMw
MIIBwTCCAUigAwIBAgIUPvvxS7euko8ZalJR2qIMlbfuA5MwCgYIKoZIzj0EAwMw
KjEUMBIGA1UECgwLU2VsZiBTaWduZWQxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0y
NTA0MDExNDMwMTFaFw0yNjA0MDExNDMwMTFaMCoxFDASBgNVBAoMC1NlbGYgU2ln
bmVkMRIwEAYDVQQDDAlsb2NhbGhvc3QwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQW
szfO5HCWIWgKUqyXUU0pFpYgaq01RRL69XZz1CkV6XTrxMfIvvwez2886EQDL8QX
i5NpKg3qvPgWuDjVHaj4WEJe5XMNqcujxcTufBlmaQ6o4vtoK7CIHDIDldF/HRij
NjA0MDIxNjMzMjJaFw0yNzA0MDIxNjMzMjJaMCoxFDASBgNVBAoMC1NlbGYgU2ln
bmVkMRIwEAYDVQQDDAlsb2NhbGhvc3QwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT5
l93hAf9RYmRKfAE3JpbJIgsr1pHpjnUtzT99HTTLJFQpOSFqreZ7mPMzUGcrh/gW
0C59HpW2759OpbqxkC1zUcaQLiazLDGsprjXfHpFpJ5D033eefew/PysOQ5dFKej
LzAtMBYGA1UdEQQPMA2CC2V4YW1wbGUuY29tMBMGA1UdJQQMMAoGCCsGAQUFBwMB
MAoGCCqGSM49BAMDA2cAMGQCMBJ8Dxg0qX2bEZ3r6dI3UCGAUYxJDVk+XhiIY1Fm
5FJeQqhaVayCRPrPXXGZUJGY/wIwXej70FwkxHKLq+XxfHTC5CzmoOK469C9Rk9Y
ucddXM83ebFxVNgRCWetH9tDdXJ9
-----END CERTIFICATE-----
MAoGCCqGSM49BAMDA2cAMGQCMFRSS0Ti9ndTwIt8Fg0ys9RyfuUj2JxDF4bIOF5g
hLGt1ZwPOukALbwGE5riOCk7gAIwQdOdW8y8UxnODjeWAoFAUeFDgFplhrpvJAnp
0sjx8oJH4Vd15Hvhoy7ZqeCh0O8P
-----END CERTIFICATE-----
@@ -1,6 +1,6 @@
-----BEGIN PRIVATE KEY-----
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDD9v51MTOcgFIbiHbok
U+QOubosGF1u1q+D3fEUb1U2cgjCofKmPHekXTz0xu9MJi2hZANiAAQWszfO5HCW
IWgKUqyXUU0pFpYgaq01RRL69XZz1CkV6XTrxMfIvvwez2886EQDL8QXi5NpKg3q
vPgWuDjVHaj4WEJe5XMNqcujxcTufBlmaQ6o4vtoK7CIHDIDldF/HRg=
-----END PRIVATE KEY-----
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDA/iiVlk1ZaSDe6Tu7N
4Dxwa0AKR6U5OFy1U/VfvRbXs/IUJGi860oiquYLgny/eTehZANiAAT5l93hAf9R
YmRKfAE3JpbJIgsr1pHpjnUtzT99HTTLJFQpOSFqreZ7mPMzUGcrh/gW0C59HpW2
759OpbqxkC1zUcaQLiazLDGsprjXfHpFpJ5D033eefew/PysOQ5dFKc=
-----END PRIVATE KEY-----
@@ -23,11 +23,11 @@ import NIOHTTPCompression
import NIOPosix
import NIOSSL
import NIOTestUtils
import NIOTransportServices
import XCTest
#if canImport(Network)
import Network
import NIOTransportServices
#endif
final class ResponseDelayGetTests: XCTestCaseHTTPClientTestsBaseClass {
@@ -23,11 +23,11 @@ import NIOHTTPCompression
import NIOPosix
import NIOSSL
import NIOTestUtils
import NIOTransportServices
import XCTest
#if canImport(Network)
import Network
import NIOTransportServices
#endif
final class StressGetHttpsTests: XCTestCaseHTTPClientTestsBaseClass {
+18
View File
@@ -0,0 +1,18 @@
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "linkage-test",
dependencies: [
.package(name: "async-http-client", path: "../..")
],
targets: [
.executableTarget(
name: "linkageTest",
dependencies: [
.product(name: "AsyncHTTPClient", package: "async-http-client")
]
)
]
)
@@ -0,0 +1,17 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the AsyncHTTPClient open source project
//
// Copyright (c) 2026 Apple Inc. and the AsyncHTTPClient project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//
import AsyncHTTPClient
print("\(HTTPClient.shared)")
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
##===----------------------------------------------------------------------===##
##
## This source file is part of the AsyncHTTPClient open source project
##
## Copyright (c) 2026 Apple Inc. and the AsyncHTTPClient project authors
## Licensed under Apache License v2.0
##
## See LICENSE.txt for license information
## See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
##
## SPDX-License-Identifier: Apache-2.0
##
##===----------------------------------------------------------------------===##
set -eu
# Validate that we're running on Linux
if [[ "$(uname -s)" != "Linux" ]]; then
echo "Error: This script must be run on Linux. Current OS: $(uname -s)" >&2
exit 1
fi
echo "Running on Linux - proceeding with linkage test..."
# Build the linkage test package
echo "Building linkage test package..."
swift build --package-path Tests/LinkageTest
# Construct build path
build_path=$(swift build --package-path Tests/LinkageTest --show-bin-path)
binary_path=$build_path/linkageTest
# Verify the binary exists
if [[ ! -f "$binary_path" ]]; then
echo "Error: Built binary not found at $binary_path" >&2
exit 1
fi
echo "Checking linkage for binary: $binary_path"
# Run ldd and check if libFoundation.so is linked
ldd_output=$(ldd "$binary_path")
echo "LDD output:"
echo "$ldd_output"
if echo "$ldd_output" | grep -q "libFoundation.so"; then
echo "Error: Binary is linked against libFoundation.so - this indicates incorrect linkage. Ensure the full Foundation is not linked on Linux when default traits are disabled." >&2
exit 1
else
echo "Success: Binary is not linked against libFoundation.so - linkage test passed."
fi