Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
2 changes: 1 addition & 1 deletion Package@swift-6.1.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Comment thread
pavansai1 marked this conversation as resolved.
}
if let localAddress = self.key.localAddress {
do {
let socketAddress = try SocketAddress(ipAddress: localAddress, port: 0)
Expand Down Expand Up @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions Sources/AsyncHTTPClient/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}