Skip to content

Commit 4f6856d

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

5 files changed

+245
-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,58 @@
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+
#if compiler(>=6.0)
7+
/// Start & automatically shut down a new ``HTTPClient``.
8+
///
9+
/// This method allows to start & automatically dispose of a ``HTTPClient`` following the principle of Structured Concurrency.
10+
/// The ``HTTPClient`` is guaranteed to be shut down upon return, whether `body` throws or not.
11+
///
12+
/// This may be particularly useful if you cannot use the shared singleton (``HTTPClient/shared``).
13+
public static func withHTTPClient<Return>(
14+
eventLoopGroup: any EventLoopGroup = HTTPClient.defaultEventLoopGroup,
15+
configuration: Configuration = Configuration(),
16+
backgroundActivityLogger: Logger? = nil,
17+
isolation: isolated (any Actor)? = #isolation,
18+
_ body: (HTTPClient) async throws -> Return
19+
) async throws -> Return {
20+
let logger = (backgroundActivityLogger ?? HTTPClient.loggingDisabled)
21+
let httpClient = HTTPClient(
22+
eventLoopGroup: eventLoopGroup,
23+
configuration: configuration,
24+
backgroundActivityLogger: logger
25+
)
26+
return try await asyncDo {
27+
try await body(httpClient)
28+
} finally: { _ in
29+
try await httpClient.shutdown()
30+
}
31+
}
32+
#else
33+
/// Start & automatically shut down a new ``HTTPClient``.
34+
///
35+
/// This method allows to start & automatically dispose of a ``HTTPClient`` following the principle of Structured Concurrency.
36+
/// The ``HTTPClient`` is guaranteed to be shut down upon return, whether `body` throws or not.
37+
///
38+
/// This may be particularly useful if you cannot use the shared singleton (``HTTPClient/shared``).
39+
public static func withHTTPClient<Return: Sendable>(
40+
eventLoopGroup: any EventLoopGroup = HTTPClient.defaultEventLoopGroup,
41+
configuration: Configuration = Configuration(),
42+
backgroundActivityLogger: Logger? = nil,
43+
_ body: (HTTPClient) async throws -> Return
44+
) async throws -> Return {
45+
let logger = (backgroundActivityLogger ?? HTTPClient.loggingDisabled)
46+
let httpClient = HTTPClient(
47+
eventLoopGroup: eventLoopGroup,
48+
configuration: configuration,
49+
backgroundActivityLogger: logger
50+
)
51+
return try await asyncDo {
52+
try await body(httpClient)
53+
} finally: { _ in
54+
try await httpClient.shutdown()
55+
}
56+
}
57+
#endif
58+
}

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,86 @@
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+
43+
func testClientIsShutDownAfterReturn() async throws {
44+
let leakedClient = try await HTTPClient.withHTTPClient { httpClient in
45+
httpClient
46+
}
47+
do {
48+
try await leakedClient.shutdown()
49+
XCTFail("unexpected, shutdown should have failed")
50+
} catch let error as HTTPClientError where error == .alreadyShutdown {
51+
// OK
52+
} catch {
53+
XCTFail("unexpected error: \(error)")
54+
}
55+
}
56+
57+
func testClientIsShutDownOnThrowAlso() async throws {
58+
struct TestError: Error {
59+
var httpClient: HTTPClient
60+
}
61+
62+
let leakedClient: HTTPClient
63+
do {
64+
try await HTTPClient.withHTTPClient { httpClient in
65+
throw TestError(httpClient: httpClient)
66+
}
67+
XCTFail("unexpected, shutdown should have failed")
68+
return
69+
} catch let error as TestError {
70+
// OK
71+
leakedClient = error.httpClient
72+
} catch {
73+
XCTFail("unexpected error: \(error)")
74+
return
75+
}
76+
77+
do {
78+
try await leakedClient.shutdown()
79+
XCTFail("unexpected, shutdown should have failed")
80+
} catch let error as HTTPClientError where error == .alreadyShutdown {
81+
// OK
82+
} catch {
83+
XCTFail("unexpected error: \(error)")
84+
}
85+
}
86+
}

0 commit comments

Comments
 (0)