From 2ddbc5ea392d6ddd2c64754c3000dce2e3e7e741 Mon Sep 17 00:00:00 2001 From: Pavan sai kumar alladi Date: Fri, 22 May 2026 11:56:12 -0700 Subject: [PATCH 1/3] Add randomized DNS resolver option for load balancing --- .../HTTPConnectionPool+Factory.swift | 12 +++ Sources/AsyncHTTPClient/HTTPClient.swift | 39 ++++++++ ...andomizedDNSResolverIntegrationTests.swift | 95 +++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 Tests/AsyncHTTPClientTests/RandomizedDNSResolverIntegrationTests.swift diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift index a9679a3de..402651909 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift @@ -486,6 +486,12 @@ extension HTTPConnectionPool.ConnectionFactory { nioBootstrap .connectTimeout(deadline - NIODeadline.now()) .enableMPTCP(clientConfiguration.enableMultipath) + switch clientConfiguration.dnsResolver.backing { + case .system: + break + case .randomized: + bootstrap = bootstrap.resolver(NIORandomizedDNSResolver(loop: eventLoop)) + } if let localAddress = self.key.localAddress { do { let socketAddress = try SocketAddress(ipAddress: localAddress, port: 0) @@ -638,6 +644,12 @@ extension HTTPConnectionPool.ConnectionFactory { var bootstrap = ClientBootstrap(group: eventLoop) .connectTimeout(deadline - NIODeadline.now()) .enableMPTCP(clientConfiguration.enableMultipath) + switch clientConfiguration.dnsResolver.backing { + case .system: + break + case .randomized: + bootstrap = bootstrap.resolver(NIORandomizedDNSResolver(loop: eventLoop)) + } if let localAddress = key.localAddress { do { let socketAddress = try SocketAddress(ipAddress: localAddress, port: 0) diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index dbc40984f..75af86824 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -847,6 +847,21 @@ public final class HTTPClient: Sendable { /// ``HTTPClient`` will still request certificates from the server for `example.com` and validate them as if we would connect to `example.com`. public var dnsOverride: [String: String] = [:] + /// Controls which DNS resolver is used for hostname resolution. + /// + /// By default, the system resolver (`getaddrinfo`) is used, which returns addresses + /// in the order produced by the platform's `getaddrinfo` (typically following + /// RFC 6724 destination-address selection). Set to ``DNSResolver/randomized`` to + /// shuffle addresses for DNS-based load balancing with services that have multiple + /// A/AAAA records (e.g. Kubernetes headless services). + /// + /// - Note: This setting has no effect when connections run on an `NIOTSEventLoopGroup`, + /// which is the default on Apple platforms (macOS 10.14+, iOS/tvOS 12+, watchOS 6+). + /// Network.framework performs its own DNS resolution and does not expose a resolver hook. + /// To use the randomized resolver there, pass a `MultiThreadedEventLoopGroup` via + /// ``HTTPClient/EventLoopGroupProvider/shared(_:)``. + public var dnsResolver: DNSResolver = .system + /// Enables following 3xx redirects automatically. /// /// Following redirects are supported: @@ -1418,6 +1433,30 @@ extension HTTPClient.Configuration { } } + /// Controls which DNS resolver is used for hostname resolution. + public struct DNSResolver: Sendable, Hashable { + enum Backing: Sendable, Hashable { + case system + case randomized + } + + let backing: Backing + + private init(backing: Backing) { + self.backing = backing + } + + /// Use the system's default DNS resolver (`getaddrinfo`). + /// Addresses are returned in the order produced by the platform's `getaddrinfo`, + /// typically following RFC 6724 destination-address selection. + public static let system: Self = .init(backing: .system) + + /// Use a randomized DNS resolver that shuffles the addresses + /// returned by `getaddrinfo`. This enables DNS-based load balancing + /// for services with multiple A/AAAA records (e.g. Kubernetes headless services). + public static let randomized: Self = .init(backing: .randomized) + } + public struct HTTPVersion: Sendable, Hashable { enum Configuration: String { case http1Only diff --git a/Tests/AsyncHTTPClientTests/RandomizedDNSResolverIntegrationTests.swift b/Tests/AsyncHTTPClientTests/RandomizedDNSResolverIntegrationTests.swift new file mode 100644 index 000000000..26b88a745 --- /dev/null +++ b/Tests/AsyncHTTPClientTests/RandomizedDNSResolverIntegrationTests.swift @@ -0,0 +1,95 @@ +//===----------------------------------------------------------------------===// +// +// 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 NIOCore +import NIOHTTP1 +import NIOPosix +import NIOSSL +import XCTest + +@testable import AsyncHTTPClient + +final class RandomizedDNSResolverIntegrationTests: XCTestCase { + + func testDefaultDNSResolverIsSystem() { + let config = HTTPClient.Configuration() + XCTAssertEqual(config.dnsResolver, .system) + } + + func testRandomizedDNSResolverCanBeSet() { + var config = HTTPClient.Configuration() + config.dnsResolver = .randomized + XCTAssertEqual(config.dnsResolver, .randomized) + } + + func testDNSResolverEquality() { + XCTAssertEqual( + HTTPClient.Configuration.DNSResolver.system, + HTTPClient.Configuration.DNSResolver.system + ) + XCTAssertEqual( + HTTPClient.Configuration.DNSResolver.randomized, + HTTPClient.Configuration.DNSResolver.randomized + ) + XCTAssertNotEqual( + HTTPClient.Configuration.DNSResolver.system, + HTTPClient.Configuration.DNSResolver.randomized + ) + } + + /// Connect over plain HTTP through the `ClientBootstrap` factory path + /// in `HTTPConnectionPool+Factory.swift`, exercising the `dnsResolver` + /// switch for both `.system` and `.randomized`. + func testResolverConnectsOverPlainHTTP() async throws { + try await self.runConnectTest(ssl: false, resolver: .system) + try await self.runConnectTest(ssl: false, resolver: .randomized) + } + + /// Connect over HTTPS through the TLS `ClientBootstrap` factory path + /// in `HTTPConnectionPool+Factory.swift`, exercising the `dnsResolver` + /// switch for both `.system` and `.randomized`. + func testResolverConnectsOverHTTPS() async throws { + try await self.runConnectTest(ssl: true, resolver: .system) + try await self.runConnectTest(ssl: true, resolver: .randomized) + } + + private func runConnectTest( + ssl: Bool, + resolver: HTTPClient.Configuration.DNSResolver + ) async throws { + let bin = HTTPBin(.http1_1(ssl: ssl, compress: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + var config = HTTPClient.Configuration() + config.dnsResolver = resolver + if ssl { + config.tlsConfiguration = .clientDefault + config.tlsConfiguration?.certificateVerification = .none + } + + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { XCTAssertNoThrow(try group.syncShutdownGracefully()) } + + let client = HTTPClient( + eventLoopGroupProvider: .shared(group), + configuration: config + ) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + let scheme = ssl ? "https" : "http" + let request = HTTPClientRequest(url: "\(scheme)://localhost:\(bin.port)/get") + let response = try await client.execute(request, deadline: .now() + .seconds(5)) + XCTAssertEqual(response.status, .ok) + } +} From 2db9c375d95f46c24f68ce45bbeb694e49afbbf3 Mon Sep 17 00:00:00 2001 From: Pavan sai kumar alladi Date: Tue, 26 May 2026 15:49:10 -0700 Subject: [PATCH 2/3] update swift nio version --- Package.swift | 2 +- Package@swift-6.0.swift | 2 +- Package@swift-6.1.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.swift b/Package.swift index 14f4c412a..7ab335bc6 100644 --- a/Package.swift +++ b/Package.swift @@ -35,7 +35,7 @@ let package = Package( .library(name: "AsyncHTTPClient", targets: ["AsyncHTTPClient"]) ], dependencies: [ - .package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.100.0"), .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.30.0"), .package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.36.0"), .package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.26.0"), diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index aad0c1c53..2e53e9b18 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -35,7 +35,7 @@ let package = Package( .library(name: "AsyncHTTPClient", targets: ["AsyncHTTPClient"]) ], dependencies: [ - .package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.100.0"), .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.30.0"), .package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.36.0"), .package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.26.0"), diff --git a/Package@swift-6.1.swift b/Package@swift-6.1.swift index aad0c1c53..2e53e9b18 100644 --- a/Package@swift-6.1.swift +++ b/Package@swift-6.1.swift @@ -35,7 +35,7 @@ let package = Package( .library(name: "AsyncHTTPClient", targets: ["AsyncHTTPClient"]) ], dependencies: [ - .package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.100.0"), .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.30.0"), .package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.36.0"), .package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.26.0"), From 9899354ecb9cf6f697a3bbd4ba8a551c8e21681b Mon Sep 17 00:00:00 2001 From: Pavan sai kumar alladi Date: Fri, 29 May 2026 13:40:39 -0700 Subject: [PATCH 3/3] drop support for 6.0 --- Package@swift-6.0.swift | 124 ---------------------------------------- 1 file changed, 124 deletions(-) delete mode 100644 Package@swift-6.0.swift diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift deleted file mode 100644 index 2e53e9b18..000000000 --- a/Package@swift-6.0.swift +++ /dev/null @@ -1,124 +0,0 @@ -// swift-tools-version:6.0 -//===----------------------------------------------------------------------===// -// -// This source file is part of the AsyncHTTPClient open source project -// -// Copyright (c) 2018-2019 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 PackageDescription - -let strictConcurrencyDevelopment = false - -let strictConcurrencySettings: [SwiftSetting] = { - var initialSettings: [SwiftSetting] = [] - - if strictConcurrencyDevelopment { - // -warnings-as-errors here is a workaround so that IDE-based development can - // get tripped up on -require-explicit-sendable. - initialSettings.append(.unsafeFlags(["-Xfrontend", "-require-explicit-sendable", "-warnings-as-errors"])) - } - - return initialSettings -}() - -let package = Package( - name: "async-http-client", - products: [ - .library(name: "AsyncHTTPClient", targets: ["AsyncHTTPClient"]) - ], - dependencies: [ - .package(url: "https://github.com/apple/swift-nio.git", from: "2.100.0"), - .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.30.0"), - .package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.36.0"), - .package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.26.0"), - .package(url: "https://github.com/apple/swift-nio-transport-services.git", from: "1.24.0"), - .package(url: "https://github.com/apple/swift-log.git", from: "1.7.1"), - .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"), - ], - targets: [ - .target( - name: "CAsyncHTTPClient", - cSettings: [ - .define("_GNU_SOURCE") - ] - ), - .target( - name: "AsyncHTTPClient", - dependencies: [ - .target(name: "CAsyncHTTPClient"), - .product(name: "NIO", package: "swift-nio"), - .product(name: "NIOTLS", package: "swift-nio"), - .product(name: "NIOCore", package: "swift-nio"), - .product(name: "NIOPosix", package: "swift-nio"), - .product(name: "NIOHTTP1", package: "swift-nio"), - .product(name: "NIOConcurrencyHelpers", package: "swift-nio"), - .product(name: "NIOHTTP2", package: "swift-nio-http2"), - .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: "Atomics", package: "swift-atomics"), - .product(name: "Algorithms", package: "swift-algorithms"), - // Observability support - .product(name: "Logging", package: "swift-log"), - .product(name: "Tracing", package: "swift-distributed-tracing"), - ], - swiftSettings: strictConcurrencySettings - ), - .testTarget( - name: "AsyncHTTPClientTests", - dependencies: [ - .target(name: "AsyncHTTPClient"), - .product(name: "NIOTLS", package: "swift-nio"), - .product(name: "NIOCore", package: "swift-nio"), - .product(name: "NIOConcurrencyHelpers", package: "swift-nio"), - .product(name: "NIOEmbedded", package: "swift-nio"), - .product(name: "NIOFoundationCompat", package: "swift-nio"), - .product(name: "NIOTestUtils", package: "swift-nio"), - .product(name: "NIOSSL", package: "swift-nio-ssl"), - .product(name: "NIOHTTP2", package: "swift-nio-http2"), - .product(name: "NIOSOCKS", package: "swift-nio-extras"), - .product(name: "Atomics", package: "swift-atomics"), - .product(name: "Algorithms", package: "swift-algorithms"), - // Observability support - .product(name: "Logging", package: "swift-log"), - .product(name: "InMemoryLogging", package: "swift-log"), - .product(name: "Tracing", package: "swift-distributed-tracing"), - .product(name: "InMemoryTracing", package: "swift-distributed-tracing"), - ], - resources: [ - .copy("Resources/self_signed_cert.pem"), - .copy("Resources/self_signed_key.pem"), - .copy("Resources/example.com.cert.pem"), - .copy("Resources/example.com.private-key.pem"), - ], - swiftSettings: strictConcurrencySettings - ), - ] -) - -// --- STANDARD CROSS-REPO SETTINGS DO NOT EDIT --- // -for target in package.targets { - switch target.type { - case .regular, .test, .executable: - var settings = target.swiftSettings ?? [] - // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0444-member-import-visibility.md - settings.append(.enableUpcomingFeature("MemberImportVisibility")) - target.swiftSettings = settings - case .macro, .plugin, .system, .binary: - () // not applicable - @unknown default: - () // we don't know what to do here, do nothing - } -} -// --- END: STANDARD CROSS-REPO SETTINGS DO NOT EDIT --- //