From e0977cf29052908856b0366ca8f8aeaa5bd5cac1 Mon Sep 17 00:00:00 2001 From: Johannes Weiss Date: Wed, 3 Apr 2024 13:54:00 +0100 Subject: [PATCH] HTTPClient.shared a globally shared singleton & .browserLike configuration (#705) Co-authored-by: Johannes Weiss --- README.md | 42 +++++-------- .../Configuration+BrowserLike.swift | 41 +++++++++++++ Sources/AsyncHTTPClient/Docs.docc/index.md | 60 +++---------------- Sources/AsyncHTTPClient/HTTPClient.swift | 41 +++++++++---- Sources/AsyncHTTPClient/Singleton.swift | 35 +++++++++++ .../HTTPClientTests.swift | 11 ++++ 6 files changed, 138 insertions(+), 92 deletions(-) create mode 100644 Sources/AsyncHTTPClient/Configuration+BrowserLike.swift create mode 100644 Sources/AsyncHTTPClient/Singleton.swift diff --git a/README.md b/README.md index 26be89420..871eb910b 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,9 @@ The code snippet below illustrates how to make a simple GET request to a remote ```swift import AsyncHTTPClient -let httpClient = HTTPClient(eventLoopGroupProvider: .singleton) - /// MARK: - Using Swift Concurrency let request = HTTPClientRequest(url: "https://apple.com/") -let response = try await httpClient.execute(request, timeout: .seconds(30)) +let response = try await HTTPClient.shared.execute(request, timeout: .seconds(30)) print("HTTP head", response) if response.status == .ok { let body = try await response.body.collect(upTo: 1024 * 1024) // 1 MB @@ -45,7 +43,7 @@ if response.status == .ok { /// MARK: - Using SwiftNIO EventLoopFuture -httpClient.get(url: "https://apple.com/").whenComplete { result in +HTTPClient.shared.get(url: "https://apple.com/").whenComplete { result in switch result { case .failure(let error): // process error @@ -59,7 +57,8 @@ httpClient.get(url: "https://apple.com/").whenComplete { result in } ``` -You should always shut down `HTTPClient` instances you created using `try httpClient.shutdown()`. Please note that you must not call `httpClient.shutdown` before all requests of the HTTP client have finished, or else the in-flight requests will likely fail because their network connections are interrupted. +If you create your own `HTTPClient` instances, you should shut them down using `httpClient.shutdown()` when you're done using them. Failing to do so will leak resources. + Please note that you must not call `httpClient.shutdown` before all requests of the HTTP client have finished, or else the in-flight requests will likely fail because their network connections are interrupted. ### async/await examples @@ -74,14 +73,13 @@ The default HTTP Method is `GET`. In case you need to have more control over the ```swift import AsyncHTTPClient -let httpClient = HTTPClient(eventLoopGroupProvider: .singleton) do { var request = HTTPClientRequest(url: "https://apple.com/") request.method = .POST request.headers.add(name: "User-Agent", value: "Swift HTTPClient") request.body = .bytes(ByteBuffer(string: "some data")) - let response = try await httpClient.execute(request, timeout: .seconds(30)) + let response = try await HTTPClient.shared.execute(request, timeout: .seconds(30)) if response.status == .ok { // handle response } else { @@ -90,8 +88,6 @@ do { } catch { // handle error } -// it's important to shutdown the httpClient after all requests are done, even if one failed -try await httpClient.shutdown() ``` #### Using SwiftNIO EventLoopFuture @@ -99,17 +95,11 @@ try await httpClient.shutdown() ```swift import AsyncHTTPClient -let httpClient = HTTPClient(eventLoopGroupProvider: .singleton) -defer { - // Shutdown is guaranteed to work if it's done precisely once (which is the case here). - try! httpClient.syncShutdown() -} - var request = try HTTPClient.Request(url: "https://apple.com/", method: .POST) request.headers.add(name: "User-Agent", value: "Swift HTTPClient") request.body = .string("some-body") -httpClient.execute(request: request).whenComplete { result in +HTTPClient.shared.execute(request: request).whenComplete { result in switch result { case .failure(let error): // process error @@ -124,7 +114,9 @@ httpClient.execute(request: request).whenComplete { result in ``` ### Redirects following -Enable follow-redirects behavior using the client configuration: + +The globally shared instance `HTTPClient.shared` follows redirects by default. If you create your own `HTTPClient`, you can enable the follow-redirects behavior using the client configuration: + ```swift let httpClient = HTTPClient(eventLoopGroupProvider: .singleton, configuration: HTTPClient.Configuration(followRedirects: true)) @@ -148,10 +140,9 @@ The following example demonstrates how to count the number of bytes in a streami #### Using Swift Concurrency ```swift -let httpClient = HTTPClient(eventLoopGroupProvider: .singleton) do { let request = HTTPClientRequest(url: "https://apple.com/") - let response = try await httpClient.execute(request, timeout: .seconds(30)) + let response = try await HTTPClient.shared.execute(request, timeout: .seconds(30)) print("HTTP head", response) // if defined, the content-length headers announces the size of the body @@ -174,8 +165,6 @@ do { } catch { print("request failed:", error) } -// it is important to shutdown the httpClient after all requests are done, even if one failed -try await httpClient.shutdown() ``` #### Using HTTPClientResponseDelegate and SwiftNIO EventLoopFuture @@ -235,7 +224,7 @@ class CountingDelegate: HTTPClientResponseDelegate { let request = try HTTPClient.Request(url: "https://apple.com/") let delegate = CountingDelegate() -httpClient.execute(request: request, delegate: delegate).futureResult.whenSuccess { count in +HTTPClient.shared.execute(request: request, delegate: delegate).futureResult.whenSuccess { count in print(count) } ``` @@ -248,7 +237,6 @@ asynchronously, while reporting the download progress at the same time, like in example: ```swift -let client = HTTPClient(eventLoopGroupProvider: .singleton) let request = try HTTPClient.Request( url: "https://swift.org/builds/development/ubuntu1804/latest-build.yml" ) @@ -260,7 +248,7 @@ let delegate = try FileDownloadDelegate(path: "/tmp/latest-build.yml", reportPro print("Downloaded \($0.receivedBytes) bytes so far") }) -client.execute(request: request, delegate: delegate).futureResult +HTTPClient.shared.execute(request: request, delegate: delegate).futureResult .whenSuccess { progress in if let totalBytes = progress.totalBytes { print("Final total bytes count: \(totalBytes)") @@ -272,8 +260,7 @@ client.execute(request: request, delegate: delegate).futureResult ### Unix Domain Socket Paths Connecting to servers bound to socket paths is easy: ```swift -let httpClient = HTTPClient(eventLoopGroupProvider: .singleton) -httpClient.execute( +HTTPClient.shared.execute( .GET, socketPath: "/tmp/myServer.socket", urlPath: "/path/to/resource" @@ -282,8 +269,7 @@ httpClient.execute( Connecting over TLS to a unix domain socket path is possible as well: ```swift -let httpClient = HTTPClient(eventLoopGroupProvider: .singleton) -httpClient.execute( +HTTPClient.shared.execute( .POST, secureSocketPath: "/tmp/myServer.socket", urlPath: "/path/to/resource", diff --git a/Sources/AsyncHTTPClient/Configuration+BrowserLike.swift b/Sources/AsyncHTTPClient/Configuration+BrowserLike.swift new file mode 100644 index 000000000..7af13514c --- /dev/null +++ b/Sources/AsyncHTTPClient/Configuration+BrowserLike.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2023 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 +// +//===----------------------------------------------------------------------===// + +extension HTTPClient.Configuration { + /// The ``HTTPClient/Configuration`` for ``HTTPClient/shared`` which tries to mimic the platform's default or prevalent browser as closely as possible. + /// + /// Don't rely on specific values of this configuration as they're subject to change. You can rely on them being somewhat sensible though. + /// + /// - note: At present, this configuration is nowhere close to a real browser configuration but in case of disagreements we will choose values that match + /// the default browser as closely as possible. + /// + /// Platform's default/prevalent browsers that we're trying to match (these might change over time): + /// - macOS: Safari + /// - iOS: Safari + /// - Android: Google Chrome + /// - Linux (non-Android): Google Chrome + public static var singletonConfiguration: HTTPClient.Configuration { + // To start with, let's go with these values. Obtained from Firefox's config. + return HTTPClient.Configuration( + certificateVerification: .fullVerification, + redirectConfiguration: .follow(max: 20, allowCycles: false), + timeout: Timeout(connect: .seconds(90), read: .seconds(90)), + connectionPool: .seconds(600), + proxy: nil, + ignoreUncleanSSLShutdown: false, + decompression: .enabled(limit: .ratio(10)), + backgroundActivityLogger: nil + ) + } +} diff --git a/Sources/AsyncHTTPClient/Docs.docc/index.md b/Sources/AsyncHTTPClient/Docs.docc/index.md index 82e859b03..37033e043 100644 --- a/Sources/AsyncHTTPClient/Docs.docc/index.md +++ b/Sources/AsyncHTTPClient/Docs.docc/index.md @@ -34,12 +34,6 @@ The code snippet below illustrates how to make a simple GET request to a remote ```swift import AsyncHTTPClient -let httpClient = HTTPClient(eventLoopGroupProvider: .singleton) -defer { - // Shutdown is guaranteed to work if it's done precisely once (which is the case here). - try! httpClient.syncShutdown() -} - /// MARK: - Using Swift Concurrency let request = HTTPClientRequest(url: "https://apple.com/") let response = try await httpClient.execute(request, timeout: .seconds(30)) @@ -53,7 +47,7 @@ if response.status == .ok { /// MARK: - Using SwiftNIO EventLoopFuture -httpClient.get(url: "https://apple.com/").whenComplete { result in +HTTPClient.shared.get(url: "https://apple.com/").whenComplete { result in switch result { case .failure(let error): // process error @@ -82,19 +76,13 @@ The default HTTP Method is `GET`. In case you need to have more control over the ```swift import AsyncHTTPClient -let httpClient = HTTPClient(eventLoopGroupProvider: .singleton) -defer { - // Shutdown is guaranteed to work if it's done precisely once (which is the case here). - try! httpClient.syncShutdown() -} - do { var request = HTTPClientRequest(url: "https://apple.com/") request.method = .POST request.headers.add(name: "User-Agent", value: "Swift HTTPClient") request.body = .bytes(ByteBuffer(string: "some data")) - let response = try await httpClient.execute(request, timeout: .seconds(30)) + let response = try await HTTPClient.shared.execute(request, timeout: .seconds(30)) if response.status == .ok { // handle response } else { @@ -103,8 +91,6 @@ do { } catch { // handle error } -// it's important to shutdown the httpClient after all requests are done, even if one failed -try await httpClient.shutdown() ``` #### Using SwiftNIO EventLoopFuture @@ -112,17 +98,11 @@ try await httpClient.shutdown() ```swift import AsyncHTTPClient -let httpClient = HTTPClient(eventLoopGroupProvider: .singleton) -defer { - // Shutdown is guaranteed to work if it's done precisely once (which is the case here). - try! httpClient.syncShutdown() -} - var request = try HTTPClient.Request(url: "https://apple.com/", method: .POST) request.headers.add(name: "User-Agent", value: "Swift HTTPClient") request.body = .string("some-body") -httpClient.execute(request: request).whenComplete { result in +HTTPClient.shared.execute(request: request).whenComplete { result in switch result { case .failure(let error): // process error @@ -161,15 +141,9 @@ The following example demonstrates how to count the number of bytes in a streami ##### Using Swift Concurrency ```swift -let httpClient = HTTPClient(eventLoopGroupProvider: .singleton) -defer { - // Shutdown is guaranteed to work if it's done precisely once (which is the case here). - try! httpClient.syncShutdown() -} - do { let request = HTTPClientRequest(url: "https://apple.com/") - let response = try await httpClient.execute(request, timeout: .seconds(30)) + let response = try await HTTPClient.shared.execute(request, timeout: .seconds(30)) print("HTTP head", response) // if defined, the content-length headers announces the size of the body @@ -192,8 +166,6 @@ do { } catch { print("request failed:", error) } -// it is important to shutdown the httpClient after all requests are done, even if one failed -try await httpClient.shutdown() ``` ##### Using HTTPClientResponseDelegate and SwiftNIO EventLoopFuture @@ -266,12 +238,6 @@ asynchronously, while reporting the download progress at the same time, like in example: ```swift -let client = HTTPClient(eventLoopGroupProvider: .singleton) -defer { - // Shutdown is guaranteed to work if it's done precisely once (which is the case here). - try! httpClient.syncShutdown() -} - let request = try HTTPClient.Request( url: "https://swift.org/builds/development/ubuntu1804/latest-build.yml" ) @@ -283,7 +249,7 @@ let delegate = try FileDownloadDelegate(path: "/tmp/latest-build.yml", reportPro print("Downloaded \($0.receivedBytes) bytes so far") }) -client.execute(request: request, delegate: delegate).futureResult +HTTPClient.shared.execute(request: request, delegate: delegate).futureResult .whenSuccess { progress in if let totalBytes = progress.totalBytes { print("Final total bytes count: \(totalBytes)") @@ -295,13 +261,7 @@ client.execute(request: request, delegate: delegate).futureResult #### Unix Domain Socket Paths Connecting to servers bound to socket paths is easy: ```swift -let httpClient = HTTPClient(eventLoopGroupProvider: .singleton) -defer { - // Shutdown is guaranteed to work if it's done precisely once (which is the case here). - try! httpClient.syncShutdown() -} - -httpClient.execute( +HTTPClient.shared.execute( .GET, socketPath: "/tmp/myServer.socket", urlPath: "/path/to/resource" @@ -310,13 +270,7 @@ httpClient.execute( Connecting over TLS to a unix domain socket path is possible as well: ```swift -let httpClient = HTTPClient(eventLoopGroupProvider: .singleton) -defer { - // Shutdown is guaranteed to work if it's done precisely once (which is the case here). - try! httpClient.syncShutdown() -} - -httpClient.execute( +HTTPClient.shared.execute( .POST, secureSocketPath: "/tmp/myServer.socket", urlPath: "/path/to/resource", diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index 683532933..6c5a9af20 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -44,8 +44,7 @@ let globalRequestID = ManagedAtomic(0) /// Example: /// /// ```swift -/// let client = HTTPClient(eventLoopGroupProvider: .singleton) -/// client.get(url: "https://swift.org", deadline: .now() + .seconds(1)).whenComplete { result in +/// HTTPClient.shared.get(url: "https://swift.org", deadline: .now() + .seconds(1)).whenComplete { result in /// switch result { /// case .failure(let error): /// // process error @@ -58,12 +57,6 @@ let globalRequestID = ManagedAtomic(0) /// } /// } /// ``` -/// -/// It is important to close the client instance, for example in a defer statement, after use to cleanly shutdown the underlying NIO `EventLoopGroup`: -/// -/// ```swift -/// try client.syncShutdown() -/// ``` public class HTTPClient { /// The `EventLoopGroup` in use by this ``HTTPClient``. /// @@ -78,6 +71,7 @@ public class HTTPClient { private var state: State private let stateLock = NIOLock() + private let canBeShutDown: Bool static let loggingDisabled = Logger(label: "AHC-do-not-log", factory: { _ in SwiftLogNoOpLogHandler() }) @@ -133,9 +127,20 @@ public class HTTPClient { /// - eventLoopGroup: The `EventLoopGroup` that the ``HTTPClient`` will use. /// - configuration: Client configuration. /// - backgroundActivityLogger: The `Logger` that will be used to log background any activity that's not associated with a request. - public required init(eventLoopGroup: any EventLoopGroup = HTTPClient.defaultEventLoopGroup, - configuration: Configuration = Configuration(), - backgroundActivityLogger: Logger) { + public convenience init(eventLoopGroup: any EventLoopGroup = HTTPClient.defaultEventLoopGroup, + configuration: Configuration = Configuration(), + backgroundActivityLogger: Logger) { + self.init(eventLoopGroup: eventLoopGroup, + configuration: configuration, + backgroundActivityLogger: backgroundActivityLogger, + canBeShutDown: true) + } + + internal required init(eventLoopGroup: EventLoopGroup, + configuration: Configuration = Configuration(), + backgroundActivityLogger: Logger, + canBeShutDown: Bool) { + self.canBeShutDown = canBeShutDown self.eventLoopGroup = eventLoopGroup self.configuration = configuration self.poolManager = HTTPConnectionPool.Manager( @@ -238,6 +243,12 @@ public class HTTPClient { } private func shutdown(requiresCleanClose: Bool, queue: DispatchQueue, _ callback: @escaping ShutdownCallback) { + guard self.canBeShutDown else { + queue.async { + callback(HTTPClientError.shutdownUnsupported) + } + return + } do { try self.stateLock.withLock { guard case .upAndRunning = self.state else { @@ -1081,6 +1092,7 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { case getConnectionFromPoolTimeout case deadlineExceeded case httpEndReceivedAfterHeadWith1xx + case shutdownUnsupported } private var code: Code @@ -1164,6 +1176,8 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { return "Deadline exceeded" case .httpEndReceivedAfterHeadWith1xx: return "HTTP end received after head with 1xx" + case .shutdownUnsupported: + return "The global singleton HTTP client cannot be shut down" } } @@ -1230,6 +1244,11 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { return HTTPClientError(code: .serverOfferedUnsupportedApplicationProtocol(proto)) } + /// The globally shared singleton ``HTTPClient`` cannot be shut down. + public static var shutdownUnsupported: HTTPClientError { + return HTTPClientError(code: .shutdownUnsupported) + } + /// The request deadline was exceeded. The request was cancelled because of this. public static let deadlineExceeded = HTTPClientError(code: .deadlineExceeded) diff --git a/Sources/AsyncHTTPClient/Singleton.swift b/Sources/AsyncHTTPClient/Singleton.swift new file mode 100644 index 000000000..149f7586f --- /dev/null +++ b/Sources/AsyncHTTPClient/Singleton.swift @@ -0,0 +1,35 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2023 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 +// +//===----------------------------------------------------------------------===// + +extension HTTPClient { + /// A globally shared, singleton ``HTTPClient``. + /// + /// The returned client uses the following settings: + /// - configuration is ``HTTPClient/Configuration/singletonConfiguration`` (matching the platform's default/prevalent browser as well as possible) + /// - `EventLoopGroup` is ``HTTPClient/defaultEventLoopGroup`` (matching the platform default) + /// - logging is disabled + public static var shared: HTTPClient { + return globallySharedHTTPClient + } +} + +private let globallySharedHTTPClient: HTTPClient = { + let httpClient = HTTPClient( + eventLoopGroup: HTTPClient.defaultEventLoopGroup, + configuration: .singletonConfiguration, + backgroundActivityLogger: HTTPClient.loggingDisabled, + canBeShutDown: false + ) + return httpClient +}() diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift index cdf9aa219..2f1b7035a 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift @@ -3575,6 +3575,17 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { } @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + func testSingletonClientWorks() throws { + let response = try HTTPClient.shared.get(url: self.defaultHTTPBinURLPrefix + "get").wait() + XCTAssertEqual(.ok, response.status) + } + + func testSingletonClientCannotBeShutDown() { + XCTAssertThrowsError(try HTTPClient.shared.shutdown().wait()) { error in + XCTAssertEqual(.shutdownUnsupported, error as? HTTPClientError) + } + } + func testAsyncExecuteWithCustomTLS() async throws { let httpsBin = HTTPBin(.http1_1(ssl: true)) defer {