Skip to content

Commit b4b82ee

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

5 files changed

+113
-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,20 @@
1+
@inlinable
2+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
3+
internal func asyncDo<R: Sendable>(
4+
_ body: @escaping @Sendable () async throws -> R,
5+
finally: @escaping @Sendable ((any Error)?) async throws -> Void
6+
) async throws -> R {
7+
do {
8+
let result = try await body()
9+
try await finally(nil)
10+
return result
11+
} catch {
12+
// This _looks_ unstructured but isn't really because we unconditionally always await the return.
13+
// We need to have an uncancelled task here to assure this is actually running in case we hit a
14+
// cancellation error.
15+
try await Task {
16+
try await finally(error)
17+
}.value
18+
throw error
19+
}
20+
}
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)