Skip to content

Commit ecef677

Browse files
author
Johannes Weiss
committed
baby steps towards a Structured Concurrency API
1 parent 81384de commit ecef677

5 files changed

+127
-2
lines changed

Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift

+12
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ extension HTTPClient {
2626
/// - request: HTTP request to execute.
2727
/// - deadline: Point in time by which the request must complete.
2828
/// - logger: The logger to use for this request.
29+
///
30+
/// - warning: This method may violates Structured Concurrency because it returns a `HTTPClientResponse` that needs to be
31+
/// streamed by the user. This means the request, the connection and other resources are still alive when the request returns.
32+
///
2933
/// - Returns: The response to the request. Note that the `body` of the response may not yet have been fully received.
3034
public func execute(
3135
_ request: HTTPClientRequest,
@@ -51,6 +55,10 @@ extension HTTPClient {
5155
/// - request: HTTP request to execute.
5256
/// - timeout: time the the request has to complete.
5357
/// - logger: The logger to use for this request.
58+
///
59+
/// - warning: This method may violates Structured Concurrency because it returns a `HTTPClientResponse` that needs to be
60+
/// streamed by the user. This means the request, the connection and other resources are still alive when the request returns.
61+
///
5462
/// - Returns: The response to the request. Note that the `body` of the response may not yet have been fully received.
5563
public func execute(
5664
_ request: HTTPClientRequest,
@@ -67,6 +75,8 @@ extension HTTPClient {
6775

6876
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
6977
extension HTTPClient {
78+
/// - warning: This method may violates Structured Concurrency because it returns a `HTTPClientResponse` that needs to be
79+
/// streamed by the user. This means the request, the connection and other resources are still alive when the request returns.
7080
private func executeAndFollowRedirectsIfNeeded(
7181
_ request: HTTPClientRequest,
7282
deadline: NIODeadline,
@@ -116,6 +126,8 @@ extension HTTPClient {
116126
}
117127
}
118128

129+
/// - warning: This method may violates Structured Concurrency because it returns a `HTTPClientResponse` that needs to be
130+
/// streamed by the user. This means the request, the connection and other resources are still alive when the request returns.
119131
private func executeCancellable(
120132
_ request: HTTPClientRequest.Prepared,
121133
deadline: NIODeadline,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import NIO
2+
import Logging
3+
4+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
5+
extension HTTPClient {
6+
/// Start & automatically shut down a new ``HTTPClient``.
7+
///
8+
/// This method allows to start & automatically dispose of a ``HTTPClient`` following the principle of Structured Concurrency.
9+
/// The ``HTTPClient`` is guaranteed to be shut down upon return, whether `body` throws or not.
10+
///
11+
/// This may be particularly useful if you cannot use the shared singleton (``HTTPClient/shared``).
12+
public static func withHTTPClient<R: Sendable>(
13+
eventLoopGroup: any EventLoopGroup = HTTPClient.defaultEventLoopGroup,
14+
configuration: Configuration = Configuration(),
15+
backgroundActivityLogger: Logger? = nil,
16+
_ body: @escaping @Sendable (HTTPClient) async throws -> R
17+
) async throws -> R {
18+
let logger = (backgroundActivityLogger ?? HTTPClient.loggingDisabled)
19+
let httpClient = HTTPClient(
20+
eventLoopGroup: eventLoopGroup,
21+
configuration: configuration,
22+
backgroundActivityLogger: logger
23+
)
24+
return try await asyncDo {
25+
try await body(httpClient)
26+
} finally: { _ in
27+
try await httpClient.shutdown()
28+
}
29+
}
30+
}

Sources/AsyncHTTPClient/HTTPHandler.swift

+9-2
Original file line numberDiff line numberDiff line change
@@ -885,19 +885,26 @@ extension HTTPClient {
885885

886886
/// Provides the result of this request.
887887
///
888+
/// - warning: This method may violates Structured Concurrency because doesn't respect cancellation.
889+
///
888890
/// - returns: The value of ``futureResult`` when it completes.
889891
/// - throws: The error value of ``futureResult`` if it errors.
890892
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
891893
public func get() async throws -> Response {
892894
try await self.promise.futureResult.get()
893895
}
894896

895-
/// Cancels the request execution.
897+
/// Initiate cancellation of a HTTP request.
898+
///
899+
/// This method will return immeidately and doesn't wait for the cancellation to complete.
896900
public func cancel() {
897901
self.fail(reason: HTTPClientError.cancelled)
898902
}
899903

900-
/// Cancels the request execution with a custom `Error`.
904+
/// Initiate cancellation of a HTTP request with an `error`.
905+
///
906+
/// This method will return immeidately and doesn't wait for the cancellation to complete.
907+
///
901908
/// - Parameter error: the error that is used to fail the promise
902909
public func fail(reason error: Error) {
903910
let taskDelegate = self.lock.withLock { () -> HTTPClientTaskDelegate? in
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the AsyncHTTPClient open source project
4+
//
5+
// Copyright (c) 2025 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+
@inlinable
16+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
17+
internal func asyncDo<R: Sendable>(
18+
_ body: @escaping @Sendable () async throws -> R,
19+
finally: @escaping @Sendable ((any Error)?) async throws -> Void
20+
) async throws -> R {
21+
do {
22+
let result = try await body()
23+
try await finally(nil)
24+
return result
25+
} catch {
26+
// This _looks_ unstructured but isn't really because we unconditionally always await the return.
27+
// We need to have an uncancelled task here to assure this is actually running in case we hit a
28+
// cancellation error.
29+
try await Task {
30+
try await finally(error)
31+
}.value
32+
throw error
33+
}
34+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import AsyncHTTPClient
2+
import XCTest
3+
import NIO
4+
import NIOFoundationCompat
5+
6+
final class HTTPClientStructuredConcurrencyTests: XCTestCase {
7+
func testDoNothingWorks() async throws {
8+
let actual = try await HTTPClient.withHTTPClient { httpClient in
9+
"OK"
10+
}
11+
XCTAssertEqual("OK", actual)
12+
}
13+
14+
func testShuttingDownTheClientInBodyLeadsToError() async {
15+
do {
16+
let actual = try await HTTPClient.withHTTPClient { httpClient in
17+
try await httpClient.shutdown()
18+
return "OK"
19+
}
20+
XCTFail("Expected error, got \(actual)")
21+
} catch let error as HTTPClientError where error == .alreadyShutdown {
22+
// OK
23+
} catch {
24+
XCTFail("unexpected error: \(error)")
25+
}
26+
}
27+
28+
func testBasicRequest() async throws {
29+
let httpBin = HTTPBin()
30+
defer { XCTAssertNoThrow(try httpBin.shutdown()) }
31+
32+
let actualBytes = try await HTTPClient.withHTTPClient { httpClient in
33+
let response = try await httpClient.get(url: httpBin.baseURL).get()
34+
XCTAssertEqual(response.status, .ok)
35+
return response.body ?? ByteBuffer(string: "n/a")
36+
}
37+
let actual = try JSONDecoder().decode(RequestInfo.self, from: actualBytes)
38+
39+
XCTAssertGreaterThanOrEqual(actual.requestNumber, 0)
40+
XCTAssertGreaterThanOrEqual(actual.connectionNumber, 0)
41+
}
42+
}

0 commit comments

Comments
 (0)