-
Notifications
You must be signed in to change notification settings - Fork 123
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
baby steps towards a Structured Concurrency API (#806)
At the moment, `HTTPClient`'s entire API surface violates Structured Concurrency. Both the creation & shutdown of a HTTP client as well as making requests (#807) doesn't follow Structured Concurrency. Some of the problems are: 1. Upon return of methods, resources are still in active use in other threads/tasks 2. Cancellation doesn't always work This PR is baby steps towards a Structured Concurrency API, starting with start/shutdown of the HTTP client. Co-authored-by: Johannes Weiss <johannes@jweiss.io>
- Loading branch information
Showing
5 changed files
with
273 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
72 changes: 72 additions & 0 deletions
72
Sources/AsyncHTTPClient/HTTPClient+StructuredConcurrency.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
//===----------------------------------------------------------------------===// | ||
// | ||
// This source file is part of the AsyncHTTPClient open source project | ||
// | ||
// Copyright (c) 2025 Apple Inc. and the AsyncHTTPClient project authors | ||
// Licensed under Apache License v2.0 | ||
// | ||
// See LICENSE.txt for license information | ||
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors | ||
// | ||
// SPDX-License-Identifier: Apache-2.0 | ||
// | ||
//===----------------------------------------------------------------------===// | ||
|
||
import Logging | ||
import NIO | ||
|
||
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) | ||
extension HTTPClient { | ||
#if compiler(>=6.0) | ||
/// Start & automatically shut down a new ``HTTPClient``. | ||
/// | ||
/// This method allows to start & automatically dispose of a ``HTTPClient`` following the principle of Structured Concurrency. | ||
/// The ``HTTPClient`` is guaranteed to be shut down upon return, whether `body` throws or not. | ||
/// | ||
/// This may be particularly useful if you cannot use the shared singleton (``HTTPClient/shared``). | ||
public static func withHTTPClient<Return>( | ||
eventLoopGroup: any EventLoopGroup = HTTPClient.defaultEventLoopGroup, | ||
configuration: Configuration = Configuration(), | ||
backgroundActivityLogger: Logger? = nil, | ||
isolation: isolated (any Actor)? = #isolation, | ||
_ body: (HTTPClient) async throws -> Return | ||
) async throws -> Return { | ||
let logger = (backgroundActivityLogger ?? HTTPClient.loggingDisabled) | ||
let httpClient = HTTPClient( | ||
eventLoopGroup: eventLoopGroup, | ||
configuration: configuration, | ||
backgroundActivityLogger: logger | ||
) | ||
return try await asyncDo { | ||
try await body(httpClient) | ||
} finally: { _ in | ||
try await httpClient.shutdown() | ||
} | ||
} | ||
#else | ||
/// Start & automatically shut down a new ``HTTPClient``. | ||
/// | ||
/// This method allows to start & automatically dispose of a ``HTTPClient`` following the principle of Structured Concurrency. | ||
/// The ``HTTPClient`` is guaranteed to be shut down upon return, whether `body` throws or not. | ||
/// | ||
/// This may be particularly useful if you cannot use the shared singleton (``HTTPClient/shared``). | ||
public static func withHTTPClient<Return: Sendable>( | ||
eventLoopGroup: any EventLoopGroup = HTTPClient.defaultEventLoopGroup, | ||
configuration: Configuration = Configuration(), | ||
backgroundActivityLogger: Logger? = nil, | ||
_ body: (HTTPClient) async throws -> Return | ||
) async throws -> Return { | ||
let logger = (backgroundActivityLogger ?? HTTPClient.loggingDisabled) | ||
let httpClient = HTTPClient( | ||
eventLoopGroup: eventLoopGroup, | ||
configuration: configuration, | ||
backgroundActivityLogger: logger | ||
) | ||
return try await asyncDo { | ||
try await body(httpClient) | ||
} finally: { _ in | ||
try await httpClient.shutdown() | ||
} | ||
} | ||
#endif | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
80 changes: 80 additions & 0 deletions
80
Sources/AsyncHTTPClient/StructuredConcurrencyHelpers.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
//===----------------------------------------------------------------------===// | ||
// | ||
// This source file is part of the AsyncHTTPClient open source project | ||
// | ||
// Copyright (c) 2025 Apple Inc. and the AsyncHTTPClient project authors | ||
// Licensed under Apache License v2.0 | ||
// | ||
// See LICENSE.txt for license information | ||
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors | ||
// | ||
// SPDX-License-Identifier: Apache-2.0 | ||
// | ||
//===----------------------------------------------------------------------===// | ||
|
||
#if compiler(>=6.0) | ||
@inlinable | ||
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) | ||
internal func asyncDo<R>( | ||
isolation: isolated (any Actor)? = #isolation, | ||
_ body: () async throws -> sending R, | ||
finally: sending @escaping ((any Error)?) async throws -> Void | ||
) async throws -> sending R { | ||
let result: R | ||
do { | ||
result = try await body() | ||
} catch { | ||
// `body` failed, we need to invoke `finally` with the `error`. | ||
|
||
// This _looks_ unstructured but isn't really because we unconditionally always await the return. | ||
// We need to have an uncancelled task here to assure this is actually running in case we hit a | ||
// cancellation error. | ||
try await Task { | ||
try await finally(error) | ||
}.value | ||
throw error | ||
} | ||
|
||
// `body` succeeded, we need to invoke `finally` with `nil` (no error). | ||
|
||
// This _looks_ unstructured but isn't really because we unconditionally always await the return. | ||
// We need to have an uncancelled task here to assure this is actually running in case we hit a | ||
// cancellation error. | ||
try await Task { | ||
try await finally(nil) | ||
}.value | ||
return result | ||
} | ||
#else | ||
@inlinable | ||
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) | ||
internal func asyncDo<R: Sendable>( | ||
_ body: () async throws -> R, | ||
finally: @escaping @Sendable ((any Error)?) async throws -> Void | ||
) async throws -> R { | ||
let result: R | ||
do { | ||
result = try await body() | ||
} catch { | ||
// `body` failed, we need to invoke `finally` with the `error`. | ||
|
||
// This _looks_ unstructured but isn't really because we unconditionally always await the return. | ||
// We need to have an uncancelled task here to assure this is actually running in case we hit a | ||
// cancellation error. | ||
try await Task { | ||
try await finally(error) | ||
}.value | ||
throw error | ||
} | ||
|
||
// `body` succeeded, we need to invoke `finally` with `nil` (no error). | ||
|
||
// This _looks_ unstructured but isn't really because we unconditionally always await the return. | ||
// We need to have an uncancelled task here to assure this is actually running in case we hit a | ||
// cancellation error. | ||
try await Task { | ||
try await finally(nil) | ||
}.value | ||
return result | ||
} | ||
#endif |
100 changes: 100 additions & 0 deletions
100
Tests/AsyncHTTPClientTests/HTTPClient+StructuredConcurrencyTests.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
//===----------------------------------------------------------------------===// | ||
// | ||
// This source file is part of the AsyncHTTPClient open source project | ||
// | ||
// Copyright (c) 2025 Apple Inc. and the AsyncHTTPClient project authors | ||
// Licensed under Apache License v2.0 | ||
// | ||
// See LICENSE.txt for license information | ||
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors | ||
// | ||
// SPDX-License-Identifier: Apache-2.0 | ||
// | ||
//===----------------------------------------------------------------------===// | ||
|
||
import AsyncHTTPClient | ||
import NIO | ||
import NIOFoundationCompat | ||
import XCTest | ||
|
||
final class HTTPClientStructuredConcurrencyTests: XCTestCase { | ||
func testDoNothingWorks() async throws { | ||
let actual = try await HTTPClient.withHTTPClient { httpClient in | ||
"OK" | ||
} | ||
XCTAssertEqual("OK", actual) | ||
} | ||
|
||
func testShuttingDownTheClientInBodyLeadsToError() async { | ||
do { | ||
let actual = try await HTTPClient.withHTTPClient { httpClient in | ||
try await httpClient.shutdown() | ||
return "OK" | ||
} | ||
XCTFail("Expected error, got \(actual)") | ||
} catch let error as HTTPClientError where error == .alreadyShutdown { | ||
// OK | ||
} catch { | ||
XCTFail("unexpected error: \(error)") | ||
} | ||
} | ||
|
||
func testBasicRequest() async throws { | ||
let httpBin = HTTPBin() | ||
defer { XCTAssertNoThrow(try httpBin.shutdown()) } | ||
|
||
let actualBytes = try await HTTPClient.withHTTPClient { httpClient in | ||
let response = try await httpClient.get(url: httpBin.baseURL).get() | ||
XCTAssertEqual(response.status, .ok) | ||
return response.body ?? ByteBuffer(string: "n/a") | ||
} | ||
let actual = try JSONDecoder().decode(RequestInfo.self, from: actualBytes) | ||
|
||
XCTAssertGreaterThanOrEqual(actual.requestNumber, 0) | ||
XCTAssertGreaterThanOrEqual(actual.connectionNumber, 0) | ||
} | ||
|
||
func testClientIsShutDownAfterReturn() async throws { | ||
let leakedClient = try await HTTPClient.withHTTPClient { httpClient in | ||
httpClient | ||
} | ||
do { | ||
try await leakedClient.shutdown() | ||
XCTFail("unexpected, shutdown should have failed") | ||
} catch let error as HTTPClientError where error == .alreadyShutdown { | ||
// OK | ||
} catch { | ||
XCTFail("unexpected error: \(error)") | ||
} | ||
} | ||
|
||
func testClientIsShutDownOnThrowAlso() async throws { | ||
struct TestError: Error { | ||
var httpClient: HTTPClient | ||
} | ||
|
||
let leakedClient: HTTPClient | ||
do { | ||
try await HTTPClient.withHTTPClient { httpClient in | ||
throw TestError(httpClient: httpClient) | ||
} | ||
XCTFail("unexpected, shutdown should have failed") | ||
return | ||
} catch let error as TestError { | ||
// OK | ||
leakedClient = error.httpClient | ||
} catch { | ||
XCTFail("unexpected error: \(error)") | ||
return | ||
} | ||
|
||
do { | ||
try await leakedClient.shutdown() | ||
XCTFail("unexpected, shutdown should have failed") | ||
} catch let error as HTTPClientError where error == .alreadyShutdown { | ||
// OK | ||
} catch { | ||
XCTFail("unexpected error: \(error)") | ||
} | ||
} | ||
} |