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.1.swift b/Package@swift-6.1.swift index 5b012b6b1..eb896f89b 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"), 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) + } +}