diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 0000000..8bb8a14 --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,47 @@ +name: Documentation + +on: + push: + branches: + - main + +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + publish: + name: Publish Documentation + # TODO: Use `macos-latest` after the macOS 13 image graduates to GA. + # https://github.com/actions/runner-images/issues/7508#issuecomment-1718206371 + runs-on: macos-13 + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Set up GitHub Pages + uses: actions/configure-pages@v3 + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - name: Print Swift compiler version + run: "swift --version" + - uses: actions/checkout@v3 + - name: Generate documentation + run: "swift package generate-documentation --target HTTPErrorHandling --disable-indexing --include-extended-types --transform-for-static-hosting --hosting-base-path swift-http-error-handling" + - name: Upload documentation + uses: actions/upload-pages-artifact@v2 + with: + path: ".build/plugins/Swift-DocC/outputs/HTTPErrorHandling.doccarchive" + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v2 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..df7389e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,35 @@ +name: Tests + +on: [push] + +# TODO: Add Windows job after Swift is added to the Windows images [1] or after +# `swift-actions/setup-swift` supports Swift 5.9+ on Windows [2]. +# 1. https://github.com/actions/runner-images/issues/8281 +# 2. https://github.com/swift-actions/setup-swift/pull/470#issuecomment-1718406382 +jobs: + test-macos: + name: Run Tests on macOS + # TODO: Use `macos-latest` after the macOS 13 image graduates to GA. + # https://github.com/actions/runner-images/issues/7508#issuecomment-1718206371 + runs-on: macos-13 + + steps: + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - name: Print Swift compiler version + run: "swift --version" + - uses: actions/checkout@v3 + - name: Run tests + run: "swift test --parallel" + + test-linux: + name: Run Tests on Linux + runs-on: ubuntu-latest + + steps: + - name: Print Swift compiler version + run: "swift --version" + - uses: actions/checkout@v3 + - name: Run tests + run: "swift test --parallel" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b29812 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..aefd1b7 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,3 @@ +version: 1 +external_links: + documentation: "https://fumoboy007.github.io/swift-http-error-handling/documentation/httperrorhandling/" diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..fae353c --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,9 @@ +# MIT License + +Copyright © 2024 Darren Mo. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..11d9f8c --- /dev/null +++ b/Package.resolved @@ -0,0 +1,50 @@ +{ + "pins" : [ + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin.git", + "state" : { + "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "1827dc94bdab2eb5f2fc804e9b0cb43574282566", + "version" : "1.0.2" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "532d8b529501fb73a2455b179e0bbb6d49b652ed", + "version" : "1.5.3" + } + }, + { + "identity" : "swift-retry", + "kind" : "remoteSourceControl", + "location" : "https://github.com/fumoboy007/swift-retry.git", + "state" : { + "revision" : "64405c301b19d298a152ca57b36d2f58e5bc20af", + "version" : "0.2.2" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..7425f6c --- /dev/null +++ b/Package.swift @@ -0,0 +1,61 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "swift-http-error-handling", + platforms: [ + .visionOS(.v1), + .macOS(.v13), + .macCatalyst(.v16), + .iOS(.v16), + .tvOS(.v16), + .watchOS(.v9), + ], + products: [ + .library( + name: "HTTPErrorHandling", + targets: [ + "HTTPErrorHandling", + ] + ), + // According to SE-0356, Swift Package Manager does not yet officially support snippet-only dependencies. + // This library product and the corresponding target work around that limitation. The product name is + // prefixed with an underscore to convey that the product was not meant to be externally visible. + .library( + name: "_AdditionalSnippetDependencies", + targets: [ + "_AdditionalSnippetDependencies", + ] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-http-types.git", from: "1.0.2"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.5.3"), + .package(url: "https://github.com/fumoboy007/swift-retry.git", .upToNextMinor(from: "0.2.2")), + ], + targets: [ + .target( + name: "HTTPErrorHandling", + dependencies: [ + .product(name: "DMRetry", package: "swift-retry"), + .product(name: "HTTPTypes", package: "swift-http-types"), + ] + ), + .testTarget( + name: "HTTPErrorHandlingTests", + dependencies: [ + "HTTPErrorHandling", + .product(name: "HTTPTypes", package: "swift-http-types"), + ] + ), + .target( + name: "_AdditionalSnippetDependencies", + dependencies: [ + .product(name: "HTTPTypesFoundation", package: "swift-http-types"), + .product(name: "Logging", package: "swift-log"), + ] + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..c124e41 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# swift-http-error-handling + +Interpret HTTP responses and handle failures in Swift. + +![Swift 5.9](https://img.shields.io/badge/swift-v5.9-%23F05138) +![Linux, visionOS 1, macOS 13, iOS 16, tvOS 16, watchOS 9](https://img.shields.io/badge/platform-Linux%20%7C%20visionOS%201%20%7C%20macOS%2013%20%7C%20iOS%2016%20%7C%20tvOS%2016%20%7C%20watchOS%209-blue) +![MIT License](https://img.shields.io/github/license/fumoboy007/swift-http-error-handling) +![Automated Tests Workflow Status](https://img.shields.io/github/actions/workflow/status/fumoboy007/swift-http-error-handling/tests.yml?event=push&label=tests) + +## Overview + +In the HTTP protocol, a client sends a request to a server and the server sends a response back to the client. The response contains a [status code](https://httpwg.org/specs/rfc9110.html#overview.of.status.codes) to help the client interpret the response. + +HTTP libraries like `Foundation` pass the response through to the caller without interpreting the response as a success or failure. `HTTPErrorHandling` can help the caller interpret HTTP responses and handle failures. + +The module works with any HTTP library that is compatible with Swift’s [standard HTTP request and response types](https://github.com/apple/swift-http-types). The module can be used on its own in code that directly uses an HTTP library, or the module can be used as a building block by higher-level networking libraries. + +## Example Usage + +```swift +import Foundation +import HTTPErrorHandling +import HTTPTypes +import HTTPTypesFoundation + +let request = HTTPRequest(method: .get, + scheme: "https", + authority: "example.com", + path: "/") + +let responseBody = try await request.retry { request in + let (responseBody, response) = try await URLSession.shared.data(for: request) + try response.throwIfFailed() + return responseBody +} +``` + +See the [documentation](https://fumoboy007.github.io/swift-http-error-handling/documentation/httperrorhandling/) for more examples. diff --git a/Snippets/Interoperability With Popular HTTP Libraries/RetryHTTPRequestUsingURLSession.swift b/Snippets/Interoperability With Popular HTTP Libraries/RetryHTTPRequestUsingURLSession.swift new file mode 100644 index 0000000..217307a --- /dev/null +++ b/Snippets/Interoperability With Popular HTTP Libraries/RetryHTTPRequestUsingURLSession.swift @@ -0,0 +1,29 @@ +// Retry an HTTP request using `URLSession`. + +// snippet.hide + +#if canImport(Darwin) + +// snippet.show + +import Foundation +import HTTPErrorHandling +import HTTPTypes +import HTTPTypesFoundation + +let request = HTTPRequest(method: .get, + scheme: "https", + authority: "example.com", + path: "/") + +let responseBody = try await request.retry { request in + let (responseBody, response) = try await URLSession.shared.data(for: request) + try response.throwIfFailed() + return responseBody +} + +// snippet.hide + +print(responseBody) + +#endif diff --git a/Snippets/Interpreting HTTP Responses/CustomHTTPResponseInterpretation.swift b/Snippets/Interpreting HTTP Responses/CustomHTTPResponseInterpretation.swift new file mode 100644 index 0000000..60b35b1 --- /dev/null +++ b/Snippets/Interpreting HTTP Responses/CustomHTTPResponseInterpretation.swift @@ -0,0 +1,25 @@ +// Customize the interpretation of the HTTP response status. + +// snippet.hide + +import HTTPErrorHandling +import HTTPTypes + +let request = HTTPRequest(method: .post, + scheme: "https", + authority: "example.com", + path: "/") + +// snippet.show + +let response = try await perform(request) +try response.throwIfFailed( + successStatuses: [.created], + transientFailureStatuses: HTTPResponse.Status.transientFailures.union([.conflict]) +) + +// snippet.hide + +func perform(_ request: HTTPRequest) async throws -> HTTPResponse { + return HTTPResponse(status: .ok) +} diff --git a/Snippets/Interpreting HTTP Responses/DefaultHTTPResponseInterpretation.swift b/Snippets/Interpreting HTTP Responses/DefaultHTTPResponseInterpretation.swift new file mode 100644 index 0000000..300d327 --- /dev/null +++ b/Snippets/Interpreting HTTP Responses/DefaultHTTPResponseInterpretation.swift @@ -0,0 +1,22 @@ +// Throw an error if the HTTP response represents a failure. + +// snippet.hide + +import HTTPErrorHandling +import HTTPTypes + +let request = HTTPRequest(method: .get, + scheme: "https", + authority: "example.com", + path: "/") + +// snippet.show + +let response = try await perform(request) +try response.throwIfFailed() + +// snippet.hide + +func perform(_ request: HTTPRequest) async throws -> HTTPResponse { + return HTTPResponse(status: .ok) +} diff --git a/Snippets/Interpreting HTTP Responses/HTTPApplicationErrorWithResponseBody.swift b/Snippets/Interpreting HTTP Responses/HTTPApplicationErrorWithResponseBody.swift new file mode 100644 index 0000000..ece0542 --- /dev/null +++ b/Snippets/Interpreting HTTP Responses/HTTPApplicationErrorWithResponseBody.swift @@ -0,0 +1,40 @@ +// Attach the response body to ``HTTPApplicationError`` and access it later. + +// snippet.hide + +import Foundation +import HTTPErrorHandling +import HTTPTypes + +let request = HTTPRequest(method: .get, + scheme: "https", + authority: "example.com", + path: "/") + +// snippet.show + +do { + let (responseBody, response) = try await perform(request) + try await response.throwIfFailed { + return try await deserializeFailureDetails(from: responseBody) + } +} catch let error as HTTPApplicationError { + let failureDetails = error.responseBody + doSomething(with: failureDetails) +} + +// snippet.hide + +func perform(_ request: HTTPRequest) async throws -> (Data, HTTPResponse) { + return (Data(), HTTPResponse(status: .ok)) +} + +struct MyFailureDetails { +} + +func deserializeFailureDetails(from responseBody: Data) async throws -> MyFailureDetails { + return MyFailureDetails() +} + +func doSomething(with failureDetails: MyFailureDetails) { +} diff --git a/Snippets/Retrying HTTP Requests/BasicRetry.swift b/Snippets/Retrying HTTP Requests/BasicRetry.swift new file mode 100644 index 0000000..f606277 --- /dev/null +++ b/Snippets/Retrying HTTP Requests/BasicRetry.swift @@ -0,0 +1,24 @@ +// Retry an HTTP request. + +// snippet.hide + +import HTTPErrorHandling +import HTTPTypes + +let request = HTTPRequest(method: .get, + scheme: "https", + authority: "example.com", + path: "/") + +// snippet.show + +try await request.retry { request in + let response = try await perform(request) + try response.throwIfFailed() +} + +// snippet.hide + +func perform(_ request: HTTPRequest) async throws -> HTTPResponse { + return HTTPResponse(status: .ok) +} diff --git a/Snippets/Retrying HTTP Requests/ConfigureRetryBehavior.swift b/Snippets/Retrying HTTP Requests/ConfigureRetryBehavior.swift new file mode 100644 index 0000000..a1f6c4f --- /dev/null +++ b/Snippets/Retrying HTTP Requests/ConfigureRetryBehavior.swift @@ -0,0 +1,30 @@ +// Configure the retry behavior. + +// snippet.hide + +import HTTPErrorHandling +import HTTPTypes +import Logging + +let request = HTTPRequest(method: .get, + scheme: "https", + authority: "example.com", + path: "/") + +// snippet.show + +try await request.retry(maxAttempts: 5, + backoff: .default(baseDelay: .milliseconds(500), + maxDelay: .seconds(10)), + logger: myLogger) { request in + let response = try await perform(request) + try response.throwIfFailed() +} + +// snippet.hide + +let myLogger = Logger(label: "Example Code") + +func perform(_ request: HTTPRequest) async throws -> HTTPResponse { + return HTTPResponse(status: .ok) +} diff --git a/Snippets/Retrying HTTP Requests/ReuseRetryConfiguration.swift b/Snippets/Retrying HTTP Requests/ReuseRetryConfiguration.swift new file mode 100644 index 0000000..8d045e4 --- /dev/null +++ b/Snippets/Retrying HTTP Requests/ReuseRetryConfiguration.swift @@ -0,0 +1,39 @@ +// Encapsulate retry behavior in a ``RetryConfiguration`` instance. + +// snippet.hide + +import HTTPErrorHandling +import HTTPTypes +import Logging +import Retry + +let request = HTTPRequest(method: .get, + scheme: "https", + authority: "example.com", + path: "/") + +// snippet.show + +extension RetryConfiguration { + static let standard = RetryConfiguration() + + static let highTolerance = ( + Self.standard + .withMaxAttempts(10) + .withBackoff(.default(baseDelay: .seconds(1), + maxDelay: nil)) + ) +} + +try await request.retry(with: .highTolerance.withLogger(myLogger)) { request in + let response = try await perform(request) + try response.throwIfFailed() +} + +// snippet.hide + +let myLogger = Logger(label: "Example Code") + +func perform(_ request: HTTPRequest) async throws -> HTTPResponse { + return HTTPResponse(status: .ok) +} diff --git a/Snippets/Retrying HTTP Requests/UseFakeClockType.swift b/Snippets/Retrying HTTP Requests/UseFakeClockType.swift new file mode 100644 index 0000000..625c729 --- /dev/null +++ b/Snippets/Retrying HTTP Requests/UseFakeClockType.swift @@ -0,0 +1,84 @@ +// Use a fake `Clock` type for deterministic and efficient automated tests. + +// snippet.hide + +import Foundation +import HTTPErrorHandling +import HTTPTypes +import XCTest + +// snippet.show + +final class MyServiceImplementation where ClockType.Duration == Duration { + private let clock: ClockType + + init(clock: ClockType) { + self.clock = clock + } + + func reliablyPerform(_ request: HTTPRequest) async throws { + try await request.retry(clock: clock) { request in + let response = try await perform(request) + try response.throwIfFailed() + } + } +} + +final class MyServiceImplementationTests: XCTestCase { + func testReliablyPerformRequest_succeeds() async throws { + let myService = MyServiceImplementation(clock: ClockFake()) + try await myService.reliablyPerform(requestFake) + } +} + +// snippet.hide + +class ClockFake: Clock, @unchecked Sendable { + typealias Instant = ContinuousClock.Instant + + private let lock = NSLock() + + init() { + let realClock = ContinuousClock() + self._now = realClock.now + self.minimumResolution = realClock.minimumResolution + } + + private var _now: Instant + var now: Instant { + lock.lock() + defer { + lock.unlock() + } + + return _now + } + + let minimumResolution: Duration + + func sleep(until deadline: Instant, + tolerance: Duration?) async throws { + // Refactored into a non-async method so that `NSLock.lock` and `NSLock.unlock` can be used. + // Cannot use the async-safe `NSLock.withLocking` method until the following change is released: + // https://github.com/apple/swift-corelibs-foundation/pull/4736 + sleep(until: deadline) + } + + private func sleep(until deadline: Instant) { + lock.lock() + defer { + lock.unlock() + } + + _now = max(deadline, _now) + } +} + +let requestFake = HTTPRequest(method: .get, + scheme: "https", + authority: "example.com", + path: "/") + +func perform(_ request: HTTPRequest) async throws -> HTTPResponse { + return HTTPResponse(status: .ok) +} diff --git a/Sources/HTTPErrorHandling/Error/HTTPApplicationError.swift b/Sources/HTTPErrorHandling/Error/HTTPApplicationError.swift new file mode 100644 index 0000000..df581f7 --- /dev/null +++ b/Sources/HTTPErrorHandling/Error/HTTPApplicationError.swift @@ -0,0 +1,62 @@ +// MIT License +// +// Copyright © 2024 Darren Mo. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import HTTPTypes + +/// A concrete error type that contains details about an HTTP application failure. +public struct HTTPApplicationError: Error { + /// The response to the failed request. + public let response: HTTPResponse + + /// The response body, which may contain additional information about the failure. + /// + /// - SeeAlso: ``HTTPApplicationErrorProtocol/anyResponseBody`` + public let responseBody: ResponseBodyType + + /// Whether the failure is transient. + /// + /// If the failure is transient, a subsequent attempt may succeed. If the failure is not transient, subsequent + /// attempts will never succeed. + public let isTransient: Bool + + /// Initializes the error. + /// + /// - Parameters: + /// - response: The response to the failed request. + /// - responseBody: The response body, which may contain additional information about the failure. + /// - isTransient: Whether the failure is transient. If the failure is transient, a subsequent + /// attempt may succeed. If the failure is not transient, subsequent attempts will never succeed. + public init(response: HTTPResponse, + responseBody: ResponseBodyType, + isTransient: Bool) { + self.response = response + self.responseBody = responseBody + + self.isTransient = isTransient + } +} + +extension HTTPApplicationError: HTTPApplicationErrorProtocol { + public var anyResponseBody: Any { + return responseBody + } +} diff --git a/Sources/HTTPErrorHandling/Error/HTTPApplicationErrorProtocol.swift b/Sources/HTTPErrorHandling/Error/HTTPApplicationErrorProtocol.swift new file mode 100644 index 0000000..b2d889c --- /dev/null +++ b/Sources/HTTPErrorHandling/Error/HTTPApplicationErrorProtocol.swift @@ -0,0 +1,44 @@ +// MIT License +// +// Copyright © 2024 Darren Mo. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import HTTPTypes + +/// A protocol that all specialized ``HTTPApplicationError`` types conform to. +/// +/// ``HTTPApplicationError`` has a generic type parameter. This protocol can be used to match any +/// ``HTTPApplicationError`` instance, regardless of its generic type parameter. +public protocol HTTPApplicationErrorProtocol: Error { + /// The response to the failed request. + var response: HTTPResponse { get } + + /// The response body, which may contain additional information about the failure. + /// + /// The property type is `Any` because the response body type is application-specific. Consider using + /// ``HTTPApplicationError/responseBody`` when the response body type is known. + var anyResponseBody: Any { get } + + /// Whether the failure is transient. + /// + /// If the failure is transient, a subsequent attempt may succeed. If the failure is not transient, subsequent + /// attempts will never succeed. + var isTransient: Bool { get } +} diff --git a/Sources/HTTPErrorHandling/HTTPErrorHandling.docc/HTTPErrorHandling.md b/Sources/HTTPErrorHandling/HTTPErrorHandling.docc/HTTPErrorHandling.md new file mode 100644 index 0000000..efd5b92 --- /dev/null +++ b/Sources/HTTPErrorHandling/HTTPErrorHandling.docc/HTTPErrorHandling.md @@ -0,0 +1,33 @@ +# ``HTTPErrorHandling`` + +Interpret HTTP responses and handle failures. + +## Overview + +In the HTTP protocol, a client sends a request to a server and the server sends a response back to the client. The response contains a [status code](https://httpwg.org/specs/rfc9110.html#overview.of.status.codes) to help the client interpret the response. + +HTTP libraries like `Foundation` pass the response through to the caller without interpreting the response as a success or failure. `HTTPErrorHandling` can help the caller interpret HTTP responses and handle failures. + +The module works with any HTTP library that is compatible with Swift’s [standard HTTP request and response types](https://github.com/apple/swift-http-types). The module can be used on its own in code that directly uses an HTTP library, or the module can be used as a building block by higher-level networking libraries. + +## Topics + +### Examples + +- +- +- + +### Representing an HTTP Application Failure + +- ``HTTPApplicationError`` +- ``HTTPApplicationErrorProtocol`` + +### Interpreting HTTP Responses + +- ``HTTPTypes/HTTPResponse`` +- ``HTTPTypes/HTTPResponse/Status`` + +### Retrying HTTP Requests + +- ``HTTPTypes/HTTPRequest`` diff --git a/Sources/HTTPErrorHandling/HTTPErrorHandling.docc/Interoperability With Popular HTTP Libraries.md b/Sources/HTTPErrorHandling/HTTPErrorHandling.docc/Interoperability With Popular HTTP Libraries.md new file mode 100644 index 0000000..9c10239 --- /dev/null +++ b/Sources/HTTPErrorHandling/HTTPErrorHandling.docc/Interoperability With Popular HTTP Libraries.md @@ -0,0 +1,9 @@ +# Interoperability With Popular HTTP Libraries + +## Foundation + +@Snippet(path: "swift-http-error-handling/Snippets/Interoperability With Popular HTTP Libraries/RetryHTTPRequestUsingURLSession") + +## AsyncHTTPClient + +TODO: Add an example after `AsyncHTTPClient` [adopts](https://github.com/swift-server/async-http-client/issues/708#issuecomment-1871343870) `swift-http-types`. diff --git a/Sources/HTTPErrorHandling/HTTPErrorHandling.docc/Interpreting HTTP Responses.md b/Sources/HTTPErrorHandling/HTTPErrorHandling.docc/Interpreting HTTP Responses.md new file mode 100644 index 0000000..c1a3815 --- /dev/null +++ b/Sources/HTTPErrorHandling/HTTPErrorHandling.docc/Interpreting HTTP Responses.md @@ -0,0 +1,13 @@ +# Interpreting HTTP Responses + +## Default Interpretation + +@Snippet(path: "swift-http-error-handling/Snippets/Interpreting HTTP Responses/DefaultHTTPResponseInterpretation") + +## Custom Interpretation + +@Snippet(path: "swift-http-error-handling/Snippets/Interpreting HTTP Responses/CustomHTTPResponseInterpretation") + +## Attaching the Response Body to the Error + +@Snippet(path: "swift-http-error-handling/Snippets/Interpreting HTTP Responses/HTTPApplicationErrorWithResponseBody") diff --git a/Sources/HTTPErrorHandling/HTTPErrorHandling.docc/Retrying HTTP Requests.md b/Sources/HTTPErrorHandling/HTTPErrorHandling.docc/Retrying HTTP Requests.md new file mode 100644 index 0000000..c133300 --- /dev/null +++ b/Sources/HTTPErrorHandling/HTTPErrorHandling.docc/Retrying HTTP Requests.md @@ -0,0 +1,17 @@ +# Retrying HTTP Requests + +## Basic Retry + +@Snippet(path: "swift-http-error-handling/Snippets/Retrying HTTP Requests/BasicRetry") + +## Configuring the Retry Behavior + +@Snippet(path: "swift-http-error-handling/Snippets/Retrying HTTP Requests/ConfigureRetryBehavior") + +## Reusing a Configuration + +@Snippet(path: "swift-http-error-handling/Snippets/Retrying HTTP Requests/ReuseRetryConfiguration") + +## Using a Fake Clock Type For Automated Tests + +@Snippet(path: "swift-http-error-handling/Snippets/Retrying HTTP Requests/UseFakeClockType") diff --git a/Sources/HTTPErrorHandling/Interpretation/HTTPResponse+Interpretation.swift b/Sources/HTTPErrorHandling/Interpretation/HTTPResponse+Interpretation.swift new file mode 100644 index 0000000..2436de6 --- /dev/null +++ b/Sources/HTTPErrorHandling/Interpretation/HTTPResponse+Interpretation.swift @@ -0,0 +1,82 @@ +// MIT License +// +// Copyright © 2024 Darren Mo. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import HTTPTypes + +/// Adds methods to interpret the response and throw ``HTTPApplicationError`` if the response +/// represents an application failure. +extension HTTPResponse { + /// Throws ``HTTPApplicationError`` if the response represents an application failure. + /// + /// - Parameters: + /// - successStatuses: The HTTP response statuses that indicate success. + /// - transientFailuresStatuses: The HTTP response statuses that indicate the failure is transient. + /// If the failure is transient, a subsequent attempt may succeed. If the failure is not transient, subsequent + /// attempts will never succeed. + /// + /// - SeeAlso: ``throwIfFailed(successStatuses:transientFailureStatuses:makeResponseBody:)`` + public func throwIfFailed( + successStatuses: Set = HTTPResponse.Status.successes, + transientFailureStatuses: Set = HTTPResponse.Status.transientFailures + ) throws { + precondition(successStatuses.isDisjoint(with: transientFailureStatuses)) + + guard successStatuses.contains(status) else { + let isTransientFailure = transientFailureStatuses.contains(status) + + throw HTTPApplicationError(response: self, + responseBody: (), + isTransient: isTransientFailure) + } + } + + /// Throws ``HTTPApplicationError`` if the response represents an application failure. The error instance + /// includes the response body. + /// + /// - Parameters: + /// - successStatuses: The HTTP response statuses that indicate success. + /// - transientFailuresStatuses: The HTTP response statuses that indicate the failure is transient. + /// If the failure is transient, a subsequent attempt may succeed. If the failure is not transient, subsequent + /// attempts will never succeed. + /// - makeResponseBody: A closure that returns the response body, which can be accessed via + /// ``HTTPApplicationError/responseBody``. The closure is `async` because the response + /// body may not yet have been fully received. + /// + /// - SeeAlso: ``throwIfFailed(successStatuses:transientFailureStatuses:)`` + public func throwIfFailed( + successStatuses: Set = HTTPResponse.Status.successes, + transientFailureStatuses: Set = HTTPResponse.Status.transientFailures, + @_inheritActorContext @_implicitSelfCapture makeResponseBody: () async throws -> ResponseBodyType + ) async throws { + precondition(successStatuses.isDisjoint(with: transientFailureStatuses)) + + guard successStatuses.contains(status) else { + let responseBody = try await makeResponseBody() + + let isTransientFailure = transientFailureStatuses.contains(status) + + throw HTTPApplicationError(response: self, + responseBody: responseBody, + isTransient: isTransientFailure) + } + } +} diff --git a/Sources/HTTPErrorHandling/Interpretation/HTTPResponseStatus+Interpretation.swift b/Sources/HTTPErrorHandling/Interpretation/HTTPResponseStatus+Interpretation.swift new file mode 100644 index 0000000..262a09a --- /dev/null +++ b/Sources/HTTPErrorHandling/Interpretation/HTTPResponseStatus+Interpretation.swift @@ -0,0 +1,42 @@ +// MIT License +// +// Copyright © 2024 Darren Mo. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import HTTPTypes + +/// Adds static properties to access common sets of HTTP response statuses. +extension HTTPResponse.Status { + /// Statuses that are commonly used to indicate success. + public static let successes = Set((200..<300).map(HTTPResponse.Status.init)) + + /// Statuses that are commonly used to indicate transient failures. + /// + /// If the failure is transient, a subsequent attempt may succeed. If the failure is not transient, subsequent + /// attempts will never succeed. + public static let transientFailures: Set = [ + .requestTimeout, + .tooManyRequests, + .internalServerError, + .badGateway, + .serviceUnavailable, + .gatewayTimeout + ] +} diff --git a/Sources/HTTPErrorHandling/Retry/Date+FromHTTPDateString.swift b/Sources/HTTPErrorHandling/Retry/Date+FromHTTPDateString.swift new file mode 100644 index 0000000..827171c --- /dev/null +++ b/Sources/HTTPErrorHandling/Retry/Date+FromHTTPDateString.swift @@ -0,0 +1,48 @@ +// MIT License +// +// Copyright © 2024 Darren Mo. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +extension Date { + private static let dateFormats = [ + "EEE, dd MMM yyyy HH:mm:ss 'GMT'", + "EEEE, dd-MMM-yy HH:mm:ss 'GMT'", + "EEE MMM d HH:mm:ss yyyy" + ] + + init?(httpDateString: String) { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + + for dateFormat in Self.dateFormats { + dateFormatter.dateFormat = dateFormat + + if let date = dateFormatter.date(from: httpDateString) { + self = date + return + } + } + + return nil + } +} diff --git a/Sources/HTTPErrorHandling/Retry/HTTPRequest+RetryableRequest.swift b/Sources/HTTPErrorHandling/Retry/HTTPRequest+RetryableRequest.swift new file mode 100644 index 0000000..1990432 --- /dev/null +++ b/Sources/HTTPErrorHandling/Retry/HTTPRequest+RetryableRequest.swift @@ -0,0 +1,64 @@ +// MIT License +// +// Copyright © 2024 Darren Mo. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import HTTPTypes +import Retry + +/// Adds `RetryableRequest` conformance to `HTTPRequest`. +/// +/// The `RetryableRequest` conformance adds safe retry methods to `HTTPRequest`. +/// +/// - Important: The retry methods accept a closure that attempts the request. The closure must interpret the response +/// and throw ``HTTPApplicationError`` when the response represents a failure. Calling +/// ``HTTPTypes/HTTPResponse/throwIfFailed(successStatuses:transientFailureStatuses:)`` +/// is a convenient way to do so. +/// +/// The `recoverFromFailure` closure is not called when the failure is due to ``HTTPApplicationError``. The +/// retry method implementations automatically choose a recovery action for ``HTTPApplicationError`` using +/// HTTP-specific information including whether the error is transient and the value of the `Retry-After` header, +/// if present. +/// +/// - SeeAlso: [`Retry`](https://fumoboy007.github.io/swift-retry/documentation/retry/) +extension HTTPRequest: RetryableRequest { + public var isIdempotent: Bool { + return method.isIdempotent + } + + public func unsafeRetryIgnoringIdempotency( + with configuration: RetryConfiguration, + @_inheritActorContext @_implicitSelfCapture operation: (Self) async throws -> ReturnType + ) async throws -> ReturnType { + let configuration = configuration.withRecoverFromFailure { error in + switch error { + case let error as any HTTPApplicationErrorProtocol: + return RecoveryAction(error, clock: configuration.clock) + + default: + return configuration.recoverFromFailure(error) + } + } + + return try await Retry.retry(with: configuration) { + return try await operation(self) + } + } +} diff --git a/Sources/HTTPErrorHandling/Retry/HTTPRequestMethod+IsIdempotent.swift b/Sources/HTTPErrorHandling/Retry/HTTPRequestMethod+IsIdempotent.swift new file mode 100644 index 0000000..b0b6887 --- /dev/null +++ b/Sources/HTTPErrorHandling/Retry/HTTPRequestMethod+IsIdempotent.swift @@ -0,0 +1,50 @@ +// MIT License +// +// Copyright © 2024 Darren Mo. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import HTTPTypes + +extension HTTPRequest.Method { + /// Determines whether the HTTP method is idempotent. + /// + /// From the [definition](https://httpwg.org/specs/rfc9110.html#idempotent.methods): + /// > A request method is considered idempotent if the intended effect on the server of multiple + /// > identical requests with that method is the same as the effect for a single such request. + public var isIdempotent: Bool { + switch self { + case .get, + .head, + .options, + .trace, + .put, + .delete: + return true + + case .post, + .patch, + .connect: + return false + + default: + return false + } + } +} diff --git a/Sources/HTTPErrorHandling/Retry/HTTPResponse+RetryAfterValue.swift b/Sources/HTTPErrorHandling/Retry/HTTPResponse+RetryAfterValue.swift new file mode 100644 index 0000000..c55feb7 --- /dev/null +++ b/Sources/HTTPErrorHandling/Retry/HTTPResponse+RetryAfterValue.swift @@ -0,0 +1,47 @@ +// MIT License +// +// Copyright © 2024 Darren Mo. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation +import HTTPTypes + +extension HTTPResponse { + enum RetryAfterValue { + case date(Date) + case durationInSeconds(UInt) + } + + var retryAfterValue: RetryAfterValue? { + guard let retryAfterString = headerFields[.retryAfter] else { + return nil + } + + if let durationInSeconds = UInt(retryAfterString) { + return .durationInSeconds(durationInSeconds) + } + + if let date = Date(httpDateString: retryAfterString) { + return .date(date) + } + + return nil + } +} diff --git a/Sources/HTTPErrorHandling/Retry/RecoveryAction+FromHTTPApplicationError.swift b/Sources/HTTPErrorHandling/Retry/RecoveryAction+FromHTTPApplicationError.swift new file mode 100644 index 0000000..b5c79cf --- /dev/null +++ b/Sources/HTTPErrorHandling/Retry/RecoveryAction+FromHTTPApplicationError.swift @@ -0,0 +1,53 @@ +// MIT License +// +// Copyright © 2024 Darren Mo. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Retry + +extension RecoveryAction { + init(_ error: any HTTPApplicationErrorProtocol, clock: ClockType) { + guard error.isTransient else { + self = .throw + return + } + + // The HTTP `Retry-After` value is a UTC time or duration. Therefore, the value + // should only be applicable when the clock is continuous like UTC time. + guard let now = clock.now as? ContinuousClock.Instant else { + self = .retry + return + } + + switch error.response.retryAfterValue { + case .date(let nextRetryMinDate): + let nextRetryMinDelayInSeconds = nextRetryMinDate.timeIntervalSinceNow + let nextRetryMinDelay = Duration.seconds(1) * nextRetryMinDelayInSeconds + self = .retryAfter((now + nextRetryMinDelay) as! ClockType.Instant) + + case .durationInSeconds(let nextRetryMinDelayInSeconds): + let nextRetryMinDelay = Duration.seconds(1) * nextRetryMinDelayInSeconds + self = .retryAfter((now + nextRetryMinDelay) as! ClockType.Instant) + + case .none: + self = .retry + } + } +} diff --git a/Sources/_AdditionalSnippetDependencies/DummyFile.swift b/Sources/_AdditionalSnippetDependencies/DummyFile.swift new file mode 100644 index 0000000..ae95e30 --- /dev/null +++ b/Sources/_AdditionalSnippetDependencies/DummyFile.swift @@ -0,0 +1,21 @@ +// MIT License +// +// Copyright © 2024 Darren Mo. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. diff --git a/Tests/HTTPErrorHandlingTests/DateFromHTTPDateStringTests.swift b/Tests/HTTPErrorHandlingTests/DateFromHTTPDateStringTests.swift new file mode 100644 index 0000000..0789cd8 --- /dev/null +++ b/Tests/HTTPErrorHandlingTests/DateFromHTTPDateStringTests.swift @@ -0,0 +1,82 @@ +// MIT License +// +// Copyright © 2024 Darren Mo. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +@testable import HTTPErrorHandling + +import Foundation +import XCTest + +final class DateFromHTTPDateStringTests: XCTestCase { + func testPreferredFormat() throws { + let httpDateString = "Sun, 06 Nov 1994 08:49:37 GMT" + + let date = try XCTUnwrap(Date(httpDateString: httpDateString)) + + let expectedDateComponents = DateComponents( + calendar: Calendar(identifier: .gregorian), + timeZone: TimeZone(secondsFromGMT: 0), + year: 1994, + month: 11, + day: 6, + hour: 8, + minute: 49, + second: 37 + ) + XCTAssertEqual(date, expectedDateComponents.date!) + } + + func testObsoleteRFC850Format() throws { + let httpDateString = "Sunday, 06-Nov-94 08:49:37 GMT" + + let date = try XCTUnwrap(Date(httpDateString: httpDateString)) + + let expectedDateComponents = DateComponents( + calendar: Calendar(identifier: .gregorian), + timeZone: TimeZone(secondsFromGMT: 0), + year: 1994, + month: 11, + day: 6, + hour: 8, + minute: 49, + second: 37 + ) + XCTAssertEqual(date, expectedDateComponents.date!) + } + + func testObsoleteAsctimeFormat() throws { + let httpDateString = "Sun Nov 6 08:49:37 1994" + + let date = try XCTUnwrap(Date(httpDateString: httpDateString)) + + let expectedDateComponents = DateComponents( + calendar: Calendar(identifier: .gregorian), + timeZone: TimeZone(secondsFromGMT: 0), + year: 1994, + month: 11, + day: 6, + hour: 8, + minute: 49, + second: 37 + ) + XCTAssertEqual(date, expectedDateComponents.date!) + } +} diff --git a/Tests/HTTPErrorHandlingTests/Fakes/ClockFake.swift b/Tests/HTTPErrorHandlingTests/Fakes/ClockFake.swift new file mode 100644 index 0000000..e3aaa4a --- /dev/null +++ b/Tests/HTTPErrorHandlingTests/Fakes/ClockFake.swift @@ -0,0 +1,77 @@ +// MIT License +// +// Copyright © 2024 Darren Mo. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +class ClockFake: Clock, @unchecked Sendable { + typealias Instant = ContinuousClock.Instant + + private let lock = NSLock() + + init() { + let realClock = ContinuousClock() + self._now = realClock.now + self.minimumResolution = realClock.minimumResolution + } + + private var _now: Instant + var now: Instant { + lock.lock() + defer { + lock.unlock() + } + + return _now + } + + let minimumResolution: Duration + + func sleep(until deadline: Instant, + tolerance: Duration?) async throws { + // Refactored into a non-async method so that `NSLock.lock` and `NSLock.unlock` can be used. + // Cannot use the async-safe `NSLock.withLocking` method until the following change is released: + // https://github.com/apple/swift-corelibs-foundation/pull/4736 + sleep(until: deadline) + } + + private func sleep(until deadline: Instant) { + lock.lock() + defer { + lock.unlock() + } + + let duration = deadline - _now + _allSleepDurations.append(duration) + + _now = max(deadline, _now) + } + + private var _allSleepDurations = [Duration]() + var allSleepDurations: [Duration] { + lock.lock() + defer { + lock.unlock() + } + + return _allSleepDurations + } +} diff --git a/Tests/HTTPErrorHandlingTests/Fakes/ErrorFake.swift b/Tests/HTTPErrorHandlingTests/Fakes/ErrorFake.swift new file mode 100644 index 0000000..61556ed --- /dev/null +++ b/Tests/HTTPErrorHandlingTests/Fakes/ErrorFake.swift @@ -0,0 +1,24 @@ +// MIT License +// +// Copyright © 2024 Darren Mo. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +struct ErrorFake: Error { +} diff --git a/Tests/HTTPErrorHandlingTests/Fakes/HTTPRequest+Fakes.swift b/Tests/HTTPErrorHandlingTests/Fakes/HTTPRequest+Fakes.swift new file mode 100644 index 0000000..8bf32f7 --- /dev/null +++ b/Tests/HTTPErrorHandlingTests/Fakes/HTTPRequest+Fakes.swift @@ -0,0 +1,30 @@ +// MIT License +// +// Copyright © 2024 Darren Mo. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import HTTPTypes + +extension HTTPRequest { + static let fake = HTTPRequest(method: .get, + scheme: "https", + authority: "example.com", + path: "/") +} diff --git a/Tests/HTTPErrorHandlingTests/Fakes/HTTPResponse+Fakes.swift b/Tests/HTTPErrorHandlingTests/Fakes/HTTPResponse+Fakes.swift new file mode 100644 index 0000000..614e6fd --- /dev/null +++ b/Tests/HTTPErrorHandlingTests/Fakes/HTTPResponse+Fakes.swift @@ -0,0 +1,31 @@ +// MIT License +// +// Copyright © 2024 Darren Mo. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import HTTPTypes + +extension HTTPResponse { + static let successFake = HTTPResponse(status: .ok) + static let failureFake = nonTransientFailureFake + + static let transientFailureFake = HTTPResponse(status: .tooManyRequests) + static let nonTransientFailureFake = HTTPResponse(status: .badRequest) +} diff --git a/Tests/HTTPErrorHandlingTests/HTTPRequestRetryTests.swift b/Tests/HTTPErrorHandlingTests/HTTPRequestRetryTests.swift new file mode 100644 index 0000000..6c8e951 --- /dev/null +++ b/Tests/HTTPErrorHandlingTests/HTTPRequestRetryTests.swift @@ -0,0 +1,209 @@ +// MIT License +// +// Copyright © 2024 Darren Mo. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import HTTPErrorHandling + +import HTTPTypes +import XCTest + +final class HTTPRequestRetryTests: XCTestCase { + func testTransientHTTPApplicationError_isRetried() async throws { + var attemptCount = 0 + + try await HTTPRequest.fake.retry(clock: ClockFake()) { request in + attemptCount += 1 + + if attemptCount == 1 { + throw HTTPApplicationError(response: .transientFailureFake, + responseBody: (), + isTransient: true) + } else { + // Success. + } + } + + XCTAssertEqual(attemptCount, 2) + } + + func testTransientHTTPApplicationError_recoverFromFailureNotCalled() async throws { + var attemptCount = 0 + + try await HTTPRequest.fake.retry(clock: ClockFake()) { request in + attemptCount += 1 + + if attemptCount == 1 { + throw HTTPApplicationError(response: .transientFailureFake, + responseBody: (), + isTransient: true) + } else { + // Success. + } + } recoverFromFailure: { error in + XCTFail("`recoverFromFailure` should not be called when the error is `HTTPApplicationError`.") + return .throw + } + + XCTAssertEqual(attemptCount, 2) + } + + func testNonTransientHTTPApplicationError_notRetried() async throws { + var attemptCount = 0 + + do { + try await HTTPRequest.fake.retry(clock: ClockFake()) { request in + attemptCount += 1 + + if attemptCount == 1 { + throw HTTPApplicationError(response: .nonTransientFailureFake, + responseBody: (), + isTransient: false) + } else { + // Success. + } + } + } catch is HTTPApplicationError { + // Expected. + } + + XCTAssertEqual(attemptCount, 1) + } + + func testNonTransientHTTPApplicationError_recoverFromFailureNotCalled() async throws { + var attemptCount = 0 + + do { + try await HTTPRequest.fake.retry(clock: ClockFake()) { request in + attemptCount += 1 + + if attemptCount == 1 { + throw HTTPApplicationError(response: .nonTransientFailureFake, + responseBody: (), + isTransient: false) + } else { + // Success. + } + } recoverFromFailure: { error in + XCTFail("`recoverFromFailure` should not be called when the error is `HTTPApplicationError`.") + return .retry + } + } catch is HTTPApplicationError { + // Expected. + } + + XCTAssertEqual(attemptCount, 1) + } + + func testHTTPApplicationErrorWithRetryAfterValue_valueIsDate_sleepsAtLeastUntilDate() async throws { + let clockFake = ClockFake() + + var attemptCount = 0 + + try await HTTPRequest.fake.retry(maxAttempts: nil, + clock: clockFake, + backoff: .default(baseDelay: .seconds(1), + maxDelay: nil)) { request in + attemptCount += 1 + + if attemptCount == 1 { + var failureResponse = HTTPResponse.transientFailureFake + // Use a distant date that is practically guaranteed to never be reached. + failureResponse.headerFields[.retryAfter] = "Wed, 07 Jan 3024 00:00:00 GMT" + + throw HTTPApplicationError(response: failureResponse, + responseBody: (), + isTransient: true) + } else { + // Success. + } + } + + XCTAssertEqual(attemptCount, 2) + + let allSleepDurations = clockFake.allSleepDurations + XCTAssertEqual(allSleepDurations.count, 1) + + if let sleepDuration = allSleepDurations.first { + // The code being tested relies on `Date.now`, which returns a real timestamp. If we try to + // use a more precise assertion, the test would become non-deterministic. Instead, assert + // that the sleep duration is at least greater than a year, so we know a minimum delay is + // being enforced. + XCTAssertGreaterThan(sleepDuration, Duration.seconds(1 * 365 * 24 * 60 * 60)) + } + } + + func testHTTPApplicationErrorWithRetryAfterValue_valueIsDuration_sleepsAtLeastDuration() async throws { + let clockFake = ClockFake() + + let minDelayInSeconds = 1_000_000 + + var attemptCount = 0 + + try await HTTPRequest.fake.retry(maxAttempts: nil, clock: clockFake) { request in + attemptCount += 1 + + if attemptCount == 1 { + var failureResponse = HTTPResponse.transientFailureFake + failureResponse.headerFields[.retryAfter] = "\(minDelayInSeconds)" + + throw HTTPApplicationError(response: failureResponse, + responseBody: (), + isTransient: true) + } else { + // Success. + } + } + + XCTAssertEqual(attemptCount, 2) + + let allSleepDurations = clockFake.allSleepDurations + XCTAssertEqual(allSleepDurations.count, 1) + + if let sleepDuration = allSleepDurations.first { + XCTAssertGreaterThanOrEqual(sleepDuration, Duration.seconds(minDelayInSeconds)) + } + } + + func testNonTransientHTTPApplicationErrorWithRetryAfterValue_notRetried() async throws { + var attemptCount = 0 + + do { + try await HTTPRequest.fake.retry(maxAttempts: nil, clock: ClockFake()) { request in + attemptCount += 1 + + if attemptCount == 1 { + var failureResponse = HTTPResponse.nonTransientFailureFake + failureResponse.headerFields[.retryAfter] = "1" + + throw HTTPApplicationError(response: failureResponse, + responseBody: (), + isTransient: false) + } else { + // Success. + } + } + } catch is HTTPApplicationError { + // Expected. + } + + XCTAssertEqual(attemptCount, 1) + } +} diff --git a/Tests/HTTPErrorHandlingTests/HTTPResponseInterpretationTests.swift b/Tests/HTTPErrorHandlingTests/HTTPResponseInterpretationTests.swift new file mode 100644 index 0000000..aa087f9 --- /dev/null +++ b/Tests/HTTPErrorHandlingTests/HTTPResponseInterpretationTests.swift @@ -0,0 +1,80 @@ +// MIT License +// +// Copyright © 2024 Darren Mo. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import HTTPErrorHandling + +import HTTPTypes +import XCTest + +final class HTTPResponseInterpretationTests: XCTestCase { + func testSuccessResponse_noError() throws { + try HTTPResponse.successFake.throwIfFailed() + } + + func testFailureResponse_throwsError() throws { + let response = HTTPResponse.failureFake + + do { + try response.throwIfFailed() + } catch let error as HTTPApplicationError { + XCTAssertEqual(error.response, response) + } + } + + func testTransientFailureResponse_throwsTransientError() throws { + do { + try HTTPResponse.transientFailureFake.throwIfFailed() + } catch let error as HTTPApplicationError { + XCTAssertTrue(error.isTransient) + } + } + + func testNonTransientFailureResponse_throwsNonTransientError() throws { + do { + try HTTPResponse.nonTransientFailureFake.throwIfFailed() + } catch let error as HTTPApplicationError { + XCTAssertFalse(error.isTransient) + } + } + + func testAttachResponseBodyToError_errorHasResponseBody() async throws { + let responseBody = Data() + + do { + try await HTTPResponse.failureFake.throwIfFailed() { + return responseBody + } + } catch let error as HTTPApplicationError { + XCTAssertEqual(error.responseBody, responseBody) + } + } + + func testFailureToCreateResponseBody_throwsResponseBodyCreationError() async throws { + do { + try await HTTPResponse.failureFake.throwIfFailed() { + throw ErrorFake() + } + } catch is ErrorFake { + // Expected. + } + } +}