Skip to content

Commit bea04db

Browse files
Johannes Weissweissi
Johannes Weiss
authored andcommitted
baby steps towards a Structured Concurrency API
1 parent 81384de commit bea04db

5 files changed

+273
-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,72 @@
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+
import Logging
16+
import NIO
17+
18+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
19+
extension HTTPClient {
20+
#if compiler(>=6.0)
21+
/// Start & automatically shut down a new ``HTTPClient``.
22+
///
23+
/// This method allows to start & automatically dispose of a ``HTTPClient`` following the principle of Structured Concurrency.
24+
/// The ``HTTPClient`` is guaranteed to be shut down upon return, whether `body` throws or not.
25+
///
26+
/// This may be particularly useful if you cannot use the shared singleton (``HTTPClient/shared``).
27+
public static func withHTTPClient<Return>(
28+
eventLoopGroup: any EventLoopGroup = HTTPClient.defaultEventLoopGroup,
29+
configuration: Configuration = Configuration(),
30+
backgroundActivityLogger: Logger? = nil,
31+
isolation: isolated (any Actor)? = #isolation,
32+
_ body: (HTTPClient) async throws -> Return
33+
) async throws -> Return {
34+
let logger = (backgroundActivityLogger ?? HTTPClient.loggingDisabled)
35+
let httpClient = HTTPClient(
36+
eventLoopGroup: eventLoopGroup,
37+
configuration: configuration,
38+
backgroundActivityLogger: logger
39+
)
40+
return try await asyncDo {
41+
try await body(httpClient)
42+
} finally: { _ in
43+
try await httpClient.shutdown()
44+
}
45+
}
46+
#else
47+
/// Start & automatically shut down a new ``HTTPClient``.
48+
///
49+
/// This method allows to start & automatically dispose of a ``HTTPClient`` following the principle of Structured Concurrency.
50+
/// The ``HTTPClient`` is guaranteed to be shut down upon return, whether `body` throws or not.
51+
///
52+
/// This may be particularly useful if you cannot use the shared singleton (``HTTPClient/shared``).
53+
public static func withHTTPClient<Return: Sendable>(
54+
eventLoopGroup: any EventLoopGroup = HTTPClient.defaultEventLoopGroup,
55+
configuration: Configuration = Configuration(),
56+
backgroundActivityLogger: Logger? = nil,
57+
_ body: (HTTPClient) async throws -> Return
58+
) async throws -> Return {
59+
let logger = (backgroundActivityLogger ?? HTTPClient.loggingDisabled)
60+
let httpClient = HTTPClient(
61+
eventLoopGroup: eventLoopGroup,
62+
configuration: configuration,
63+
backgroundActivityLogger: logger
64+
)
65+
return try await asyncDo {
66+
try await body(httpClient)
67+
} finally: { _ in
68+
try await httpClient.shutdown()
69+
}
70+
}
71+
#endif
72+
}

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,80 @@
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+
#if compiler(>=6.0)
16+
@inlinable
17+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
18+
internal func asyncDo<R>(
19+
isolation: isolated (any Actor)? = #isolation,
20+
_ body: () async throws -> sending R,
21+
finally: sending @escaping ((any Error)?) async throws -> Void
22+
) async throws -> sending R {
23+
let result: R
24+
do {
25+
result = try await body()
26+
} catch {
27+
// `body` failed, we need to invoke `finally` with the `error`.
28+
29+
// This _looks_ unstructured but isn't really because we unconditionally always await the return.
30+
// We need to have an uncancelled task here to assure this is actually running in case we hit a
31+
// cancellation error.
32+
try await Task {
33+
try await finally(error)
34+
}.value
35+
throw error
36+
}
37+
38+
// `body` succeeded, we need to invoke `finally` with `nil` (no error).
39+
40+
// This _looks_ unstructured but isn't really because we unconditionally always await the return.
41+
// We need to have an uncancelled task here to assure this is actually running in case we hit a
42+
// cancellation error.
43+
try await Task {
44+
try await finally(nil)
45+
}.value
46+
return result
47+
}
48+
#else
49+
@inlinable
50+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
51+
internal func asyncDo<R: Sendable>(
52+
_ body: () async throws -> R,
53+
finally: @escaping @Sendable ((any Error)?) async throws -> Void
54+
) async throws -> R {
55+
let result: R
56+
do {
57+
result = try await body()
58+
} catch {
59+
// `body` failed, we need to invoke `finally` with the `error`.
60+
61+
// This _looks_ unstructured but isn't really because we unconditionally always await the return.
62+
// We need to have an uncancelled task here to assure this is actually running in case we hit a
63+
// cancellation error.
64+
try await Task {
65+
try await finally(error)
66+
}.value
67+
throw error
68+
}
69+
70+
// `body` succeeded, we need to invoke `finally` with `nil` (no error).
71+
72+
// This _looks_ unstructured but isn't really because we unconditionally always await the return.
73+
// We need to have an uncancelled task here to assure this is actually running in case we hit a
74+
// cancellation error.
75+
try await Task {
76+
try await finally(nil)
77+
}.value
78+
return result
79+
}
80+
#endif
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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+
import AsyncHTTPClient
16+
import NIO
17+
import NIOFoundationCompat
18+
import XCTest
19+
20+
final class HTTPClientStructuredConcurrencyTests: XCTestCase {
21+
func testDoNothingWorks() async throws {
22+
let actual = try await HTTPClient.withHTTPClient { httpClient in
23+
"OK"
24+
}
25+
XCTAssertEqual("OK", actual)
26+
}
27+
28+
func testShuttingDownTheClientInBodyLeadsToError() async {
29+
do {
30+
let actual = try await HTTPClient.withHTTPClient { httpClient in
31+
try await httpClient.shutdown()
32+
return "OK"
33+
}
34+
XCTFail("Expected error, got \(actual)")
35+
} catch let error as HTTPClientError where error == .alreadyShutdown {
36+
// OK
37+
} catch {
38+
XCTFail("unexpected error: \(error)")
39+
}
40+
}
41+
42+
func testBasicRequest() async throws {
43+
let httpBin = HTTPBin()
44+
defer { XCTAssertNoThrow(try httpBin.shutdown()) }
45+
46+
let actualBytes = try await HTTPClient.withHTTPClient { httpClient in
47+
let response = try await httpClient.get(url: httpBin.baseURL).get()
48+
XCTAssertEqual(response.status, .ok)
49+
return response.body ?? ByteBuffer(string: "n/a")
50+
}
51+
let actual = try JSONDecoder().decode(RequestInfo.self, from: actualBytes)
52+
53+
XCTAssertGreaterThanOrEqual(actual.requestNumber, 0)
54+
XCTAssertGreaterThanOrEqual(actual.connectionNumber, 0)
55+
}
56+
57+
func testClientIsShutDownAfterReturn() async throws {
58+
let leakedClient = try await HTTPClient.withHTTPClient { httpClient in
59+
httpClient
60+
}
61+
do {
62+
try await leakedClient.shutdown()
63+
XCTFail("unexpected, shutdown should have failed")
64+
} catch let error as HTTPClientError where error == .alreadyShutdown {
65+
// OK
66+
} catch {
67+
XCTFail("unexpected error: \(error)")
68+
}
69+
}
70+
71+
func testClientIsShutDownOnThrowAlso() async throws {
72+
struct TestError: Error {
73+
var httpClient: HTTPClient
74+
}
75+
76+
let leakedClient: HTTPClient
77+
do {
78+
try await HTTPClient.withHTTPClient { httpClient in
79+
throw TestError(httpClient: httpClient)
80+
}
81+
XCTFail("unexpected, shutdown should have failed")
82+
return
83+
} catch let error as TestError {
84+
// OK
85+
leakedClient = error.httpClient
86+
} catch {
87+
XCTFail("unexpected error: \(error)")
88+
return
89+
}
90+
91+
do {
92+
try await leakedClient.shutdown()
93+
XCTFail("unexpected, shutdown should have failed")
94+
} catch let error as HTTPClientError where error == .alreadyShutdown {
95+
// OK
96+
} catch {
97+
XCTFail("unexpected error: \(error)")
98+
}
99+
}
100+
}

0 commit comments

Comments
 (0)