Skip to content
Open
2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ let package = Package(
.package(url: "https://github.com/awslabs/aws-crt-swift.git", exact: "0.54.2"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"),
.package(url: "https://github.com/open-telemetry/opentelemetry-swift", from: "1.13.0"),
.package(url: "https://github.com/swift-server/async-http-client.git", from: "1.26.0"),
]

let isDocCEnabled = ProcessInfo.processInfo.environment["AWS_SWIFT_SDK_ENABLE_DOCC"] != nil
Expand Down Expand Up @@ -97,6 +98,7 @@ let package = Package(
"SmithyChecksums",
"SmithyCBOR",
.product(name: "AwsCommonRuntimeKit", package: "aws-crt-swift"),
.product(name: "AsyncHTTPClient", package: "async-http-client"),
// Only include these on macOS, iOS, tvOS, watchOS, and macCatalyst (visionOS and Linux are excluded)
.product(
name: "InMemoryExporter",
Expand Down
249 changes: 249 additions & 0 deletions Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
//
// Copyright Amazon.com Inc. or its affiliates.
// All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0
//

import AsyncHTTPClient
import NIOCore
import NIOHTTP1
import NIOPosix
import NIOSSL
import struct Smithy.Attributes
import struct Smithy.SwiftLogger
import protocol Smithy.LogAgent
import struct SmithyHTTPAPI.Headers
import struct SmithyHTTPAPI.Header
import protocol SmithyHTTPAPI.HTTPClient
import class SmithyHTTPAPI.HTTPResponse
import class SmithyHTTPAPI.HTTPRequest
import enum SmithyHTTPAPI.HTTPStatusCode
import enum SmithyHTTPAPI.HTTPMethodType
import protocol Smithy.ReadableStream
import enum Smithy.ByteStream
import class SmithyStreams.BufferedStream
import struct Foundation.Date
import struct Foundation.URLComponents
import struct Foundation.URLQueryItem
import AwsCommonRuntimeKit

/// AsyncHTTPClient-based HTTP client implementation that conforms to SmithyHTTPAPI.HTTPClient
/// This implementation is thread-safe and supports concurrent request execution.
public final class NIOHTTPClient: SmithyHTTPAPI.HTTPClient {
public static let noOpNIOHTTPClientTelemetry = HttpTelemetry(
httpScope: "NIOHTTPClient",
telemetryProvider: DefaultTelemetry.provider
)

private let client: AsyncHTTPClient.HTTPClient
private let config: HttpClientConfiguration
private let tlsConfiguration: NIOHTTPClientTLSOptions?
private let allocator: ByteBufferAllocator

/// HTTP Client Telemetry
private let telemetry: HttpTelemetry

/// Logger for HTTP-related events.
private var logger: LogAgent

/// Creates a new `NIOHTTPClient`.
///
/// The client is created with its own internal `AsyncHTTPClient`, which is configured with system defaults.
/// - Parameters:
/// - httpClientConfiguration: The configuration to use for the client's `AsyncHTTPClient` setup.
/// - eventLoopGroup: The `EventLoopGroup` that the ``HTTPClient`` will use.
public init(
httpClientConfiguration: HttpClientConfiguration,
eventLoopGroup: (any NIOCore.EventLoopGroup)? = nil
) {
self.config = httpClientConfiguration
self.telemetry = httpClientConfiguration.telemetry ?? NIOHTTPClient.noOpNIOHTTPClientTelemetry
self.logger = self.telemetry.loggerProvider.getLogger(name: "NIOHTTPClient")
self.tlsConfiguration = httpClientConfiguration.tlsConfiguration as? NIOHTTPClientTLSOptions
self.allocator = ByteBufferAllocator()

var clientConfig = AsyncHTTPClient.HTTPClient.Configuration.from(
httpClientConfiguration: httpClientConfiguration
)

// Configure TLS if options are provided
if let tlsOptions = tlsConfiguration {
do {
clientConfig.tlsConfiguration = try tlsOptions.makeNIOSSLConfiguration()
} catch {
// Log TLS configuration error but continue with default TLS settings
self.logger.error(
"Failed to configure TLS: \(String(describing: error)). Using default TLS configuration."
)
}
}

if let eventLoopGroup {
self.client = AsyncHTTPClient.HTTPClient(eventLoopGroup: eventLoopGroup, configuration: clientConfig)
} else {
self.client = AsyncHTTPClient.HTTPClient(configuration: clientConfig)
}
}

deinit {
try? client.syncShutdown()
}

public func send(request: SmithyHTTPAPI.HTTPRequest) async throws -> SmithyHTTPAPI.HTTPResponse {
let telemetryContext = telemetry.contextManager.current()
let tracer = telemetry.tracerProvider.getTracer(
scope: telemetry.tracerScope
)

// START - smithy.client.http.requests.queued_duration
let queuedStart = Date().timeIntervalSinceReferenceDate
let span = tracer.createSpan(
name: telemetry.spanName,
initialAttributes: telemetry.spanAttributes,
spanKind: SpanKind.internal,
parentContext: telemetryContext)
defer {
span.end()
}

// START - smithy.client.http.connections.acquire_duration
let acquireConnectionStart = Date().timeIntervalSinceReferenceDate

// Convert Smithy HTTPRequest to AsyncHTTPClient HTTPClientRequest
let nioRequest = try await makeNIORequest(from: request)

let acquireConnectionEnd = Date().timeIntervalSinceReferenceDate
telemetry.connectionsAcquireDuration.record(
value: acquireConnectionEnd - acquireConnectionStart,
attributes: Attributes(),
context: telemetryContext)
// END - smithy.client.http.connections.acquire_duration

let queuedEnd = acquireConnectionEnd
telemetry.requestsQueuedDuration.record(
value: queuedEnd - queuedStart,
attributes: Attributes(),
context: telemetryContext)
// END - smithy.client.http.requests.queued_duration

// Update connection and request usage metrics
telemetry.updateHTTPMetricsUsage { httpMetricsUsage in
// TICK - smithy.client.http.connections.limit
// Note: AsyncHTTPClient doesn't expose connection pool configuration publicly
httpMetricsUsage.connectionsLimit = 0

// TICK - smithy.client.http.connections.usage
// Note: AsyncHTTPClient doesn't expose current connection counts
httpMetricsUsage.acquiredConnections = 0
httpMetricsUsage.idleConnections = 0

// TICK - smithy.client.http.requests.usage
httpMetricsUsage.inflightRequests = httpMetricsUsage.acquiredConnections
httpMetricsUsage.queuedRequests = httpMetricsUsage.idleConnections
}

// DURATION - smithy.client.http.connections.uptime
let connectionUptimeStart = acquireConnectionEnd
defer {
telemetry.connectionsUptime.record(
value: Date().timeIntervalSinceReferenceDate - connectionUptimeStart,
attributes: Attributes(),
context: telemetryContext)
}

let httpMethod = request.method.rawValue
let url = request.destination.url
logger.debug("NIOHTTPClient(\(httpMethod) \(String(describing: url))) started")
logBodyDescription(request.body)

do {
let timeout: TimeAmount = .seconds(Int64(config.socketTimeout))
let nioResponse = try await client.execute(nioRequest, timeout: timeout)

// Convert NIO response to Smithy HTTPResponse
let statusCode = HTTPStatusCode(rawValue: Int(nioResponse.status.code)) ?? .insufficientStorage
var headers = Headers()
for (name, value) in nioResponse.headers {
headers.add(name: name, value: value)
}

let body = await NIOHTTPClientStreamBridge.convertResponseBody(from: nioResponse)

let response = HTTPResponse(headers: headers, body: body, statusCode: statusCode)
logger.debug("NIOHTTPClient(\(httpMethod) \(String(describing: url))) succeeded")

return response
} catch {
let urlDescription = String(describing: url)
let errorDescription = String(describing: error)
logger.error("NIOHTTPClient(\(httpMethod) \(urlDescription)) failed with error: \(errorDescription)")
throw error
}
}

/// Create an AsyncHTTPClient request from a Smithy HTTPRequest
private func makeNIORequest(
from request: SmithyHTTPAPI.HTTPRequest
) async throws -> AsyncHTTPClient.HTTPClientRequest {
var components = URLComponents()
components.scheme = config.protocolType?.rawValue ?? request.destination.scheme.rawValue
components.host = request.endpoint.uri.host
components.port = port(for: request)
components.percentEncodedPath = request.destination.path
if let queryItems = request.queryItems, !queryItems.isEmpty {
components.percentEncodedQueryItems = queryItems.map {
URLQueryItem(name: $0.name, value: $0.value)
}
}
guard let url = components.url else { throw NIOHTTPClientError.incompleteHTTPRequest }

let method = NIOHTTP1.HTTPMethod(rawValue: request.method.rawValue)
var nioRequest = AsyncHTTPClient.HTTPClientRequest(url: url.absoluteString)
nioRequest.method = method

// request headers will replace default if the same value is present in both
for header in config.defaultHeaders.headers + request.headers.headers {
for value in header.value {
nioRequest.headers.replaceOrAdd(name: header.name, value: value)
}
}

nioRequest.body = try await NIOHTTPClientStreamBridge.convertRequestBody(
from: request.body,
allocator: allocator
)

return nioRequest
}

private func port(for request: SmithyHTTPAPI.HTTPRequest) -> Int? {
switch (request.destination.scheme, request.destination.port) {
case (.https, 443), (.http, 80):
return nil
default:
return request.destination.port.map { Int($0) }
}
}

private func logBodyDescription(_ body: ByteStream) {
switch body {
case .stream(let stream):
let lengthString: String
if let length = stream.length {
lengthString = "\(length) bytes"
} else {
lengthString = "unknown length"
}
logger.debug("body is Stream (\(lengthString))")
case .data(let data):
if let data {
logger.debug("body is Data (\(data.count) bytes)")
} else {
logger.debug("body is empty")
}
case .noStream:
logger.debug("body is empty")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//
// Copyright Amazon.com Inc. or its affiliates.
// All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0
//

import AsyncHTTPClient
import NIOCore

extension HTTPClient.Configuration {

static func from(httpClientConfiguration: HttpClientConfiguration) -> HTTPClient.Configuration {
let connect: TimeAmount? = httpClientConfiguration.connectTimeout != nil
? .seconds(Int64(httpClientConfiguration.connectTimeout!))
: nil

let read: TimeAmount? = .seconds(Int64(httpClientConfiguration.socketTimeout))

let timeout = HTTPClient.Configuration.Timeout(
connect: connect,
read: read
)

let pool = HTTPClient.Configuration.ConnectionPool(
idleTimeout: .seconds(60), // default
concurrentHTTP1ConnectionsPerHostSoftLimit: httpClientConfiguration.maxConnections
)

return .init(
tlsConfiguration: nil, // TODO
redirectConfiguration: nil,
timeout: timeout,
connectionPool: pool,
proxy: nil,
ignoreUncleanSSLShutdown: false,
decompression: .disabled
)
}
}
18 changes: 18 additions & 0 deletions Sources/ClientRuntime/Networking/Http/NIO/NIOHTTPClientError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// Copyright Amazon.com Inc. or its affiliates.
// All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0
//

/// Errors that are particular to the NIO-based Smithy HTTP client.
public enum NIOHTTPClientError: Error {

/// A URL could not be formed from the `HTTPRequest`.
/// Please file a bug with aws-sdk-swift if you experience this error.
case incompleteHTTPRequest

/// An error occurred during streaming operations.
/// Please file a bug with aws-sdk-swift if you experience this error.
case streamingError(Error)
}
Loading