Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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.0.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)
}
}
Loading