Skip to content

Commit 7744c2a

Browse files
pavansai1Pavan sai kumar alladiglbrntt
authored
Add randomized DNS resolver option for load balancing (#907)
## Summary - Adds `HTTPClient.Configuration.DNSResolver` with `.system` (default) and `.randomized` cases. When `.randomized` is selected, `NIORandomizedDNSResolver` is installed on the underlying `ClientBootstrap`, shuffling addresses returned by `getaddrinfo`. - Motivation: enables DNS-based load balancing for services that publish multiple A/AAAA records (e.g. Kubernetes headless services). The system resolver returns addresses in a deterministic order (typically RFC 6724 destination-address selection), which defeats round-robin DNS — clients hammer the first address. --------- Co-authored-by: Pavan sai kumar alladi <p_alladi@apple.com> Co-authored-by: George Barnett <gbarnett@apple.com>
1 parent 83f8711 commit 7744c2a

5 files changed

Lines changed: 148 additions & 2 deletions

File tree

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ let package = Package(
3535
.library(name: "AsyncHTTPClient", targets: ["AsyncHTTPClient"])
3636
],
3737
dependencies: [
38-
.package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"),
38+
.package(url: "https://github.com/apple/swift-nio.git", from: "2.100.0"),
3939
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.30.0"),
4040
.package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.36.0"),
4141
.package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.26.0"),

Package@swift-6.1.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ let package = Package(
3535
.library(name: "AsyncHTTPClient", targets: ["AsyncHTTPClient"])
3636
],
3737
dependencies: [
38-
.package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"),
38+
.package(url: "https://github.com/apple/swift-nio.git", from: "2.100.0"),
3939
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.30.0"),
4040
.package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.36.0"),
4141
.package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.26.0"),

Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,12 @@ extension HTTPConnectionPool.ConnectionFactory {
486486
nioBootstrap
487487
.connectTimeout(deadline - NIODeadline.now())
488488
.enableMPTCP(clientConfiguration.enableMultipath)
489+
switch clientConfiguration.dnsResolver.backing {
490+
case .system:
491+
break
492+
case .randomized:
493+
bootstrap = bootstrap.resolver(NIORandomizedDNSResolver(loop: eventLoop))
494+
}
489495
if let localAddress = self.key.localAddress {
490496
do {
491497
let socketAddress = try SocketAddress(ipAddress: localAddress, port: 0)
@@ -638,6 +644,12 @@ extension HTTPConnectionPool.ConnectionFactory {
638644
var bootstrap = ClientBootstrap(group: eventLoop)
639645
.connectTimeout(deadline - NIODeadline.now())
640646
.enableMPTCP(clientConfiguration.enableMultipath)
647+
switch clientConfiguration.dnsResolver.backing {
648+
case .system:
649+
break
650+
case .randomized:
651+
bootstrap = bootstrap.resolver(NIORandomizedDNSResolver(loop: eventLoop))
652+
}
641653
if let localAddress = key.localAddress {
642654
do {
643655
let socketAddress = try SocketAddress(ipAddress: localAddress, port: 0)

Sources/AsyncHTTPClient/HTTPClient.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,21 @@ public final class HTTPClient: Sendable {
847847
/// ``HTTPClient`` will still request certificates from the server for `example.com` and validate them as if we would connect to `example.com`.
848848
public var dnsOverride: [String: String] = [:]
849849

850+
/// Controls which DNS resolver is used for hostname resolution.
851+
///
852+
/// By default, the system resolver (`getaddrinfo`) is used, which returns addresses
853+
/// in the order produced by the platform's `getaddrinfo` (typically following
854+
/// RFC 6724 destination-address selection). Set to ``DNSResolver/randomized`` to
855+
/// shuffle addresses for DNS-based load balancing with services that have multiple
856+
/// A/AAAA records (e.g. Kubernetes headless services).
857+
///
858+
/// - Note: This setting has no effect when connections run on an `NIOTSEventLoopGroup`,
859+
/// which is the default on Apple platforms (macOS 10.14+, iOS/tvOS 12+, watchOS 6+).
860+
/// Network.framework performs its own DNS resolution and does not expose a resolver hook.
861+
/// To use the randomized resolver there, pass a `MultiThreadedEventLoopGroup` via
862+
/// ``HTTPClient/EventLoopGroupProvider/shared(_:)``.
863+
public var dnsResolver: DNSResolver = .system
864+
850865
/// Enables following 3xx redirects automatically.
851866
///
852867
/// Following redirects are supported:
@@ -1418,6 +1433,30 @@ extension HTTPClient.Configuration {
14181433
}
14191434
}
14201435

1436+
/// Controls which DNS resolver is used for hostname resolution.
1437+
public struct DNSResolver: Sendable, Hashable {
1438+
enum Backing: Sendable, Hashable {
1439+
case system
1440+
case randomized
1441+
}
1442+
1443+
let backing: Backing
1444+
1445+
private init(backing: Backing) {
1446+
self.backing = backing
1447+
}
1448+
1449+
/// Use the system's default DNS resolver (`getaddrinfo`).
1450+
/// Addresses are returned in the order produced by the platform's `getaddrinfo`,
1451+
/// typically following RFC 6724 destination-address selection.
1452+
public static let system: Self = .init(backing: .system)
1453+
1454+
/// Use a randomized DNS resolver that shuffles the addresses
1455+
/// returned by `getaddrinfo`. This enables DNS-based load balancing
1456+
/// for services with multiple A/AAAA records (e.g. Kubernetes headless services).
1457+
public static let randomized: Self = .init(backing: .randomized)
1458+
}
1459+
14211460
public struct HTTPVersion: Sendable, Hashable {
14221461
enum Configuration: String {
14231462
case http1Only
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the AsyncHTTPClient open source project
4+
//
5+
// Copyright (c) 2026 Apple Inc. and the AsyncHTTPClient project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import NIOCore
16+
import NIOHTTP1
17+
import NIOPosix
18+
import NIOSSL
19+
import XCTest
20+
21+
@testable import AsyncHTTPClient
22+
23+
final class RandomizedDNSResolverIntegrationTests: XCTestCase {
24+
25+
func testDefaultDNSResolverIsSystem() {
26+
let config = HTTPClient.Configuration()
27+
XCTAssertEqual(config.dnsResolver, .system)
28+
}
29+
30+
func testRandomizedDNSResolverCanBeSet() {
31+
var config = HTTPClient.Configuration()
32+
config.dnsResolver = .randomized
33+
XCTAssertEqual(config.dnsResolver, .randomized)
34+
}
35+
36+
func testDNSResolverEquality() {
37+
XCTAssertEqual(
38+
HTTPClient.Configuration.DNSResolver.system,
39+
HTTPClient.Configuration.DNSResolver.system
40+
)
41+
XCTAssertEqual(
42+
HTTPClient.Configuration.DNSResolver.randomized,
43+
HTTPClient.Configuration.DNSResolver.randomized
44+
)
45+
XCTAssertNotEqual(
46+
HTTPClient.Configuration.DNSResolver.system,
47+
HTTPClient.Configuration.DNSResolver.randomized
48+
)
49+
}
50+
51+
/// Connect over plain HTTP through the `ClientBootstrap` factory path
52+
/// in `HTTPConnectionPool+Factory.swift`, exercising the `dnsResolver`
53+
/// switch for both `.system` and `.randomized`.
54+
func testResolverConnectsOverPlainHTTP() async throws {
55+
try await self.runConnectTest(ssl: false, resolver: .system)
56+
try await self.runConnectTest(ssl: false, resolver: .randomized)
57+
}
58+
59+
/// Connect over HTTPS through the TLS `ClientBootstrap` factory path
60+
/// in `HTTPConnectionPool+Factory.swift`, exercising the `dnsResolver`
61+
/// switch for both `.system` and `.randomized`.
62+
func testResolverConnectsOverHTTPS() async throws {
63+
try await self.runConnectTest(ssl: true, resolver: .system)
64+
try await self.runConnectTest(ssl: true, resolver: .randomized)
65+
}
66+
67+
private func runConnectTest(
68+
ssl: Bool,
69+
resolver: HTTPClient.Configuration.DNSResolver
70+
) async throws {
71+
let bin = HTTPBin(.http1_1(ssl: ssl, compress: false))
72+
defer { XCTAssertNoThrow(try bin.shutdown()) }
73+
74+
var config = HTTPClient.Configuration()
75+
config.dnsResolver = resolver
76+
if ssl {
77+
config.tlsConfiguration = .clientDefault
78+
config.tlsConfiguration?.certificateVerification = .none
79+
}
80+
81+
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
82+
defer { XCTAssertNoThrow(try group.syncShutdownGracefully()) }
83+
84+
let client = HTTPClient(
85+
eventLoopGroupProvider: .shared(group),
86+
configuration: config
87+
)
88+
defer { XCTAssertNoThrow(try client.syncShutdown()) }
89+
90+
let scheme = ssl ? "https" : "http"
91+
let request = HTTPClientRequest(url: "\(scheme)://localhost:\(bin.port)/get")
92+
let response = try await client.execute(request, deadline: .now() + .seconds(5))
93+
XCTAssertEqual(response.status, .ok)
94+
}
95+
}

0 commit comments

Comments
 (0)