From e39eb2066f306f65a86e06ab25062975c5bcebd8 Mon Sep 17 00:00:00 2001 From: fumoboy007 <2100868+fumoboy007@users.noreply.github.com> Date: Fri, 8 Dec 2023 19:01:38 -0800 Subject: [PATCH] Initial commit. --- .github/workflows/documentation.yml | 47 +++++ .github/workflows/tests.yml | 35 ++++ .gitignore | 9 + LICENSE.md | 9 + Package.resolved | 32 +++ Package.swift | 41 ++++ README.md | 12 ++ .../Backoff/Algorithms/ConstantBackoff.swift | 33 +++ .../FullJitterExponentialBackoff.swift | 67 ++++++ Sources/Retry/Backoff/Backoff.swift | 107 ++++++++++ Sources/Retry/Backoff/BackoffAlgorithm.swift | 33 +++ Sources/Retry/Retry.docc/Retry.md | 112 ++++++++++ Sources/Retry/Retry.swift | 192 ++++++++++++++++++ Sources/Retry/RetryConfiguration.swift | 113 +++++++++++ Sources/Retry/Retryable/NotRetryable.swift | 38 ++++ Sources/Retry/Retryable/Retryable.swift | 40 ++++ Tests/RetryTests/RetryTests.swift | 36 ++++ 17 files changed, 956 insertions(+) create mode 100644 .github/workflows/documentation.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/Retry/Backoff/Algorithms/ConstantBackoff.swift create mode 100644 Sources/Retry/Backoff/Algorithms/FullJitterExponentialBackoff.swift create mode 100644 Sources/Retry/Backoff/Backoff.swift create mode 100644 Sources/Retry/Backoff/BackoffAlgorithm.swift create mode 100644 Sources/Retry/Retry.docc/Retry.md create mode 100644 Sources/Retry/Retry.swift create mode 100644 Sources/Retry/RetryConfiguration.swift create mode 100644 Sources/Retry/Retryable/NotRetryable.swift create mode 100644 Sources/Retry/Retryable/Retryable.swift create mode 100644 Tests/RetryTests/RetryTests.swift diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 0000000..e11acd8 --- /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 Retry --disable-indexing --include-extended-types --transform-for-static-hosting --hosting-base-path swift-retry" + - name: Upload documentation + uses: actions/upload-pages-artifact@v2 + with: + path: ".build/plugins/Swift-DocC/outputs/Retry.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..6c13a50 --- /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" + + 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" 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/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..72561b0 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,9 @@ +# MIT License + +Copyright © 2023 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..93058c0 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,32 @@ +{ + "pins" : [ + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "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-log", + "kind" : "remoteSourceControl", + "location" : "http://github.com/apple/swift-log", + "state" : { + "revision" : "532d8b529501fb73a2455b179e0bbb6d49b652ed", + "version" : "1.5.3" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..5f4393d --- /dev/null +++ b/Package.swift @@ -0,0 +1,41 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "Retry", + platforms: [ + .visionOS(.v1), + .macOS(.v13), + .macCatalyst(.v16), + .iOS(.v16), + .tvOS(.v16), + .watchOS(.v9), + ], + products: [ + .library( + name: "Retry", + targets: [ + "Retry", + ] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.5.3"), + ], + targets: [ + .target( + name: "Retry", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + ] + ), + .testTarget( + name: "RetryTests", + dependencies: [ + "Retry", + ] + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7adb62 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# swift-retry + +Retries in Swift with good default behavior and powerful flexibility. + +![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-retry) +![Automated Tests Workflow Status](https://img.shields.io/github/actions/workflow/status/fumoboy007/swift-retry/tests.yml?event=push&label=tests) + +## Usage + +See the [documentation](https://fumoboy007.github.io/swift-retry/documentation/retry/). diff --git a/Sources/Retry/Backoff/Algorithms/ConstantBackoff.swift b/Sources/Retry/Backoff/Algorithms/ConstantBackoff.swift new file mode 100644 index 0000000..22be9d0 --- /dev/null +++ b/Sources/Retry/Backoff/Algorithms/ConstantBackoff.swift @@ -0,0 +1,33 @@ +// MIT License +// +// Copyright © 2023 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 ConstantBackoff: BackoffAlgorithm { + private let delay: ClockType.Duration + + init(delay: ClockType.Duration) { + self.delay = delay + } + + func nextDelay() -> ClockType.Duration { + return delay + } +} diff --git a/Sources/Retry/Backoff/Algorithms/FullJitterExponentialBackoff.swift b/Sources/Retry/Backoff/Algorithms/FullJitterExponentialBackoff.swift new file mode 100644 index 0000000..da9a658 --- /dev/null +++ b/Sources/Retry/Backoff/Algorithms/FullJitterExponentialBackoff.swift @@ -0,0 +1,67 @@ +// MIT License +// +// Copyright © 2023 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 FullJitterExponentialBackoff: BackoffAlgorithm { + private let clockMinResolution: ClockType.Duration + + private let baseDelayInClockTicks: Int + private let maxExponent: Int + + private var attempt = 0 + + init(clock: ClockType, + baseDelay: ClockType.Duration, + maxDelay: ClockType.Duration?) { + self.clockMinResolution = clock.minimumResolution + + self.baseDelayInClockTicks = Int((baseDelay / clockMinResolution).rounded()) + precondition(baseDelayInClockTicks > 0) + + var maxExponent = Self.closestBaseTwoExponentOfValue(lessThanOrEqualTo: Int.max / baseDelayInClockTicks) + if let maxDelay { + precondition(maxDelay >= baseDelay) + let maxDelayInClockTicks = Int((maxDelay / clockMinResolution).rounded()) + + maxExponent = min(Self.closestBaseTwoExponentOfValue(lessThanOrEqualTo: maxDelayInClockTicks), + maxExponent) + } + self.maxExponent = maxExponent + } + + private static func closestBaseTwoExponentOfValue(lessThanOrEqualTo value: Int) -> Int { + precondition(value >= 0) + + return max(Int.bitWidth - value.leadingZeroBitCount - 1, 0) + } + + mutating func nextDelay() -> ClockType.Duration { + defer { + attempt += 1 + } + + let exponent = min(attempt, maxExponent) + let maxDelayInClockTicks = baseDelayInClockTicks * 1 << exponent + + let delayInClockTicks = Int.random(in: 0...maxDelayInClockTicks) + return clockMinResolution * delayInClockTicks + } +} diff --git a/Sources/Retry/Backoff/Backoff.swift b/Sources/Retry/Backoff/Backoff.swift new file mode 100644 index 0000000..4410546 --- /dev/null +++ b/Sources/Retry/Backoff/Backoff.swift @@ -0,0 +1,107 @@ +// MIT License +// +// Copyright © 2023 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. + +/// The choice of algorithm that will be used to determine how long to sleep in between attempts. +public struct Backoff { + // MARK: - Built-In Algorithm + + /// The default algorithm, which is suitable for most use cases. + /// + /// This algorithm is an exponential backoff algorithm. The specific choice of algorithm is an implementation + /// detail, which may change in the future. + /// + /// - Parameters: + /// - baseDelay: A duration that all delays will be based on. For example, in a simple exponential + /// backoff algorithm, the first delay might be `baseDelay`, the second delay might be + /// `baseDelay * 2`, the third delay might be `baseDelay * 2 * 2`, and so on. + /// - maxDelay: The desired maximum duration in between attempts. There may also be a maximum + /// enforced by the algorithm implementation. + public static func `default`(baseDelay: ClockType.Duration, + maxDelay: ClockType.Duration?) -> Self { + return exponentialWithFullJitter(baseDelay: baseDelay, + maxDelay: maxDelay) + } + + /// Exponential backoff with “full jitter”. + /// + /// This algorithm is used by [AWS](https://docs.aws.amazon.com/sdkref/latest/guide/feature-retry-behavior.html#feature-retry-behavior-sdk-compat) + /// and [Google Cloud](https://github.com/googleapis/gax-go/blob/465d35f180e8dc8b01979d09c780a10c41f15136/v2/call_option.go#L181-L205), + /// among others. The advantages and disadvantages of the algorithm are detailed in a [blog post](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/) + /// by AWS. + /// + /// - Parameters: + /// - baseDelay: A duration that all delays will be based on. For example, in a simple exponential + /// backoff algorithm, the first delay might be `baseDelay`, the second delay might be + /// `baseDelay * 2`, the third delay might be `baseDelay * 2 * 2`, and so on. + /// - maxDelay: The desired maximum duration in between attempts. There may also be a maximum + /// enforced by the algorithm implementation. + /// + /// - SeeAlso: ``default(baseDelay:maxDelay:)`` + public static func exponentialWithFullJitter(baseDelay: ClockType.Duration, + maxDelay: ClockType.Duration?) -> Self { + return Self { clock in + return FullJitterExponentialBackoff(clock: clock, + baseDelay: baseDelay, + maxDelay: maxDelay) + } + } + + /// Constant delay. + /// + /// - Warning: This algorithm should only be used as an optimization for a small set of use cases. + /// Most retry use cases involve a resource, such as a server, with potentially many clients where an + /// exponential backoff algorithm would be ideal to avoid [DDoSing the server](https://cloud.google.com/blog/products/gcp/how-to-avoid-a-self-inflicted-ddos-attack-cre-life-lessons). + /// The constant delay algorithm should only be used in cases where there is no possibility of a DDoS. + /// + /// - Parameter delay: The constant duration to sleep in between attempts. + /// + /// - SeeAlso: ``default(baseDelay:maxDelay:)`` + public static func constant(_ delay: ClockType.Duration) -> Self { + return Self { _ in + return ConstantBackoff(delay: delay) + } + } + + // MARK: - Private Properties + + private let makeAlgorithmClosure: @Sendable (ClockType) -> any BackoffAlgorithm + + // MARK: - Initialization + + /// Initializes the instance with a specific algorithm. + /// + /// - Parameter makeAlgorithm: A closure that returns a ``BackoffAlgorithm`` implementation. + /// + /// - SeeAlso: ``default(baseDelay:maxDelay:)`` + public init(makeAlgorithm: @escaping @Sendable (ClockType) -> any BackoffAlgorithm) { + self.makeAlgorithmClosure = makeAlgorithm + } + + // MARK: - Making the Algorithm + + func makeAlgorithm(clock: ClockType) -> any BackoffAlgorithm { + return makeAlgorithmClosure(clock) + } +} + +extension Backoff: Sendable { +} diff --git a/Sources/Retry/Backoff/BackoffAlgorithm.swift b/Sources/Retry/Backoff/BackoffAlgorithm.swift new file mode 100644 index 0000000..fc19744 --- /dev/null +++ b/Sources/Retry/Backoff/BackoffAlgorithm.swift @@ -0,0 +1,33 @@ +// MIT License +// +// Copyright © 2023 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. + +/// Determines how long to sleep in between attempts. +/// +/// Implement a custom algorithm by implementing a type that conforms to this protocol. +/// Use the custom algorithm by passing a closure that returns an instance of that type +/// to ``Backoff/init(makeAlgorithm:)``. +public protocol BackoffAlgorithm { + associatedtype ClockType: Clock + + /// Determines the delay before the next attempt. + mutating func nextDelay() -> ClockType.Duration +} diff --git a/Sources/Retry/Retry.docc/Retry.md b/Sources/Retry/Retry.docc/Retry.md new file mode 100644 index 0000000..688c424 --- /dev/null +++ b/Sources/Retry/Retry.docc/Retry.md @@ -0,0 +1,112 @@ +# ``Retry`` + +Retries with good default behavior and powerful flexibility. + +## Overview + +### Basic Usage + +```swift +try await retry { + try await doSomething() +} +``` + +### Enabling/Disabling Retries for Specific Error Cases + +```swift +try await retry { + try await doSomething() +} shouldRetry: { error in + return error.isRetryable +} + +extension Error { + var isRetryable: Bool { + switch self { + case let error as MyError: + return error.isRetryable + + default: + return true + } + } +} + +extension MyError { + var isRetryable: Bool { + switch self { + case .myRetryableCase: + return true + + case .myNotRetryableCase: + return false + } + } +} +``` + +### Enabling/Disabling Retries for Specific Code Paths + +```swift +try await retry { + do { + try await doSomethingRetryable() + } catch { + throw Retryable(error) + } + + do { + try await doSomethingNotRetryable() + } catch { + throw NotRetryable(error) + } +} +``` + +### Configuring the Retry Behavior + +```swift +try await retry(maxAttempts: 10, + backoff: .default(baseDelay: .milliseconds(500), + maxDelay: .seconds(10)), + logger: myLogger) { + try await doSomething() +} +``` + +### Reusing a Configuration + +```swift +extension RetryConfiguration { + static let standard = RetryConfiguration( + maxAttempts: 5, + clock: ContinuousClock(), + backoff: .default(baseDelay: .seconds(1), + maxDelay: nil), + shouldRetry: { _ in true } + ) +} + +try await retry(with: .standard.withLogger(myLogger)) { + try await doSomething() +} +``` + +## Topics + +### Retrying Operations + +- ``retry(maxAttempts:backoff:appleLogger:logger:operation:shouldRetry:)`` +- ``retry(with:operation:)`` + +### Configuring the Retry Behavior + +- ``RetryConfiguration`` +- ``Backoff`` +- ``BackoffAlgorithm`` + +### Enabling/Disabling Retries for Specific Code Paths + +- ``Retryable`` +- ``NotRetryable`` diff --git a/Sources/Retry/Retry.swift b/Sources/Retry/Retry.swift new file mode 100644 index 0000000..c16940a --- /dev/null +++ b/Sources/Retry/Retry.swift @@ -0,0 +1,192 @@ +// MIT License +// +// Copyright © 2023 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 Logging +#if canImport(OSLog) +import OSLog +#endif + +#if canImport(OSLog) +/// Attempts the given operation until it succeeds or until the failure is no longer retryable. +/// +/// Failures may not be retryable for the following reasons: +/// - `shouldRetry` returns `false`. +/// - The thrown error is ``NotRetryable``. +/// - The number of attempts reached `maxAttempts`. +/// +/// - Parameters: +/// - maxAttempts: The maximum number of times to attempt the operation. Must be greater than `0`. +/// - backoff: The choice of algorithm that will be used to determine how long to sleep in between attempts. +/// - appleLogger: The logger that will be used to log a message when an attempt fails. The function will +/// log messages using the `debug` log level. +/// - logger: The logger that will be used to log a message when an attempt fails. The function will log +/// messages using the `debug` log level. Consider using `appleLogger` when possible. +/// - operation: The operation to attempt. +/// - shouldRetry: A closure that determines whether to retry given the error that was thrown. The closure +/// will not be called if the error is ``Retryable`` or ``NotRetryable``. +/// +/// - SeeAlso: ``retry(with:operation:)`` +public func retry( + maxAttempts: Int? = 3, + backoff: Backoff = .default(baseDelay: .seconds(1), maxDelay: .seconds(20)), + appleLogger: os.Logger? = nil, + logger: Logging.Logger? = nil, + @_inheritActorContext @_implicitSelfCapture operation: () async throws -> ReturnType, + shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } +) async throws -> ReturnType { + var configuration = RetryConfiguration(maxAttempts: maxAttempts, + clock: ContinuousClock(), + backoff: backoff, + shouldRetry: shouldRetry) + configuration.appleLogger = appleLogger + configuration.logger = logger + + return try await retry(with: configuration, + operation: operation) +} +#else +/// Attempts the given operation until it succeeds or until the failure is no longer retryable. +/// +/// Failures may not be retryable for the following reasons: +/// - `shouldRetry` returns `false`. +/// - The thrown error is ``NotRetryable``. +/// - The number of attempts reached `maxAttempts`. +/// +/// - Parameters: +/// - maxAttempts: The maximum number of times to attempt the operation. Must be greater than `0`. +/// - backoff: The choice of algorithm that will be used to determine how long to sleep in between attempts. +/// - logger: The logger that will be used to log a message when an attempt fails. The function will log +/// messages using the `debug` log level. +/// - operation: The operation to attempt. +/// - shouldRetry: A closure that determines whether to retry given the error that was thrown. The closure +/// will not be called if the error is ``Retryable`` or ``NotRetryable``. +/// +/// - SeeAlso: ``retry(with:operation:)`` +public func retry( + maxAttempts: Int? = 3, + backoff: Backoff = .default(baseDelay: .seconds(1), maxDelay: .seconds(20)), + logger: Logging.Logger? = nil, + @_inheritActorContext @_implicitSelfCapture operation: () async throws -> ReturnType, + shouldRetry: @escaping @Sendable (any Error) -> Bool = { _ in true } +) async throws -> ReturnType { + var configuration = RetryConfiguration(maxAttempts: maxAttempts, + clock: ContinuousClock(), + backoff: backoff, + shouldRetry: shouldRetry) + configuration.logger = logger + + return try await retry(with: configuration, + operation: operation) +} +#endif + +/// Attempts the given operation until it succeeds or until the failure is no longer retryable. +/// +/// Failures may not be retryable for the following reasons: +/// - ``RetryConfiguration/shouldRetry`` returns `false`. +/// - The thrown error is ``NotRetryable``. +/// - The number of attempts reached ``RetryConfiguration/maxAttempts``. +/// +/// - Parameters: +/// - configuration: Configuration that specifies the behavior of this function. +/// - operation: The operation to attempt. +/// +/// - Note: The function will log messages using the `debug` log level to ``RetryConfiguration/logger`` +/// (and/or ``RetryConfiguration/appleLogger`` on Apple platforms). +public func retry( + with configuration: RetryConfiguration, + @_inheritActorContext @_implicitSelfCapture operation: () async throws -> ReturnType +) async throws -> ReturnType { + let maxAttempts = configuration.maxAttempts + + let clock = configuration.clock + var backoff = configuration.backoff.makeAlgorithm(clock: clock) + + var logger = configuration.logger +#if canImport(OSLog) + let appleLogger = configuration.appleLogger +#endif + + let shouldRetry = configuration.shouldRetry + + var attempt = 0 + while true { + let latestError: any Error + let isErrorRetryable: Bool + + do { + return try await operation() + } catch { + switch error { + case let error as Retryable: + latestError = error.underlyingError + isErrorRetryable = true + + case let error as NotRetryable: + latestError = error.underlyingError + isErrorRetryable = false + + default: + latestError = error + isErrorRetryable = shouldRetry(error) + } + } + + logger?[metadataKey: "retry.attempt"] = "\(attempt)" + // Only log the error type in case the error has private user data. We can include the full error + // if and when the Logging API offers a distinction between public and private data. + logger?[metadataKey: "retry.error.type"] = "\(type(of: latestError))" + + if !isErrorRetryable { + logger?.debug("Attempt failed. Error is not retryable.") +#if canImport(OSLog) + appleLogger?.debug("Attempt \(attempt, privacy: .public) failed with error of type \(type(of: latestError), privacy: .public): `\(latestError)`. Error is not retryable.") +#endif + + throw latestError + } + + if let maxAttempts, attempt + 1 >= maxAttempts { + logger?.debug("Attempt failed. No remaining attempts.") +#if canImport(OSLog) + appleLogger?.debug("Attempt \(attempt, privacy: .public) failed with error of type \(type(of: latestError), privacy: .public): `\(latestError)`. No remaining attempts.") +#endif + + throw latestError + } + + let delay = backoff.nextDelay() as! ClockType.Duration + + logger?.debug("Attempt failed. Will wait before retrying.", metadata: [ + // Unfortunately, the generic `ClockType.Duration` does not have a way to convert `delay` + // to a number, so we have to settle for the implementation-defined string representation. + "retry.delay": "\(delay)" + ]) +#if canImport(OSLog) + appleLogger?.debug("Attempt \(attempt, privacy: .public) failed with error of type \(type(of: latestError), privacy: .public): `\(latestError)`. Will wait \(String(describing: delay), privacy: .public) before retrying.") +#endif + + try await clock.sleep(for: delay) + + attempt += 1 + } +} diff --git a/Sources/Retry/RetryConfiguration.swift b/Sources/Retry/RetryConfiguration.swift new file mode 100644 index 0000000..610266c --- /dev/null +++ b/Sources/Retry/RetryConfiguration.swift @@ -0,0 +1,113 @@ +// MIT License +// +// Copyright © 2023 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 Logging +#if canImport(OSLog) +// FB13460778: `Logger` does not currently conform to `Sendable` even though it is +// likely already concurrency-safe. +@preconcurrency import OSLog +#endif + +/// Configures the retry behavior. +public struct RetryConfiguration { + /// The maximum number of times to attempt the operation. + /// + /// - Precondition: Must be greater than `0`. + public var maxAttempts: Int? + + /// The clock that will be used to sleep in between attempts. + public var clock: ClockType + /// The algorithm that determines how long to wait in between attempts. + public var backoff: Backoff + +#if canImport(OSLog) + /// The logger that will be used to log a message when an attempt fails. + public var appleLogger: os.Logger? +#endif + /// The logger that will be used to log a message when an attempt fails. + /// + /// - Remark: On Apple platforms, consider using ``appleLogger`` for potentially more + /// detailed log messages and better integration with the logging system. + public var logger: Logging.Logger? + + /// A closure that determines whether to retry given the error that was thrown. + /// + /// - Note: The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. + public var shouldRetry: @Sendable (any Error) -> Bool + + public init(maxAttempts: Int?, + clock: ClockType, + backoff: Backoff, + shouldRetry: @escaping @Sendable (any Error) -> Bool) { + if let maxAttempts { + precondition(maxAttempts > 0) + } + + self.maxAttempts = maxAttempts + + self.clock = clock + self.backoff = backoff + + self.shouldRetry = shouldRetry + } + + public func withMaxAttempts(_ newValue: Int?) -> Self { + var newConfiguration = self + newConfiguration.maxAttempts = newValue + return newConfiguration + } + + public func withClock(_ newValue: ClockType) -> Self { + var newConfiguration = self + newConfiguration.clock = newValue + return newConfiguration + } + + public func withBackoff(_ newValue: Backoff) -> Self { + var newConfiguration = self + newConfiguration.backoff = newValue + return newConfiguration + } + +#if canImport(OSLog) + public func withAppleLogger(_ newValue: os.Logger?) -> Self { + var newConfiguration = self + newConfiguration.appleLogger = newValue + return newConfiguration + } +#endif + + public func withLogger(_ newValue: Logging.Logger?) -> Self { + var newConfiguration = self + newConfiguration.logger = newValue + return newConfiguration + } + + public func withShouldRetry(_ newValue: @escaping @Sendable (any Error) -> Bool) -> Self { + var newConfiguration = self + newConfiguration.shouldRetry = newValue + return newConfiguration + } +} + +extension RetryConfiguration: Sendable { +} diff --git a/Sources/Retry/Retryable/NotRetryable.swift b/Sources/Retry/Retryable/NotRetryable.swift new file mode 100644 index 0000000..87dd0e0 --- /dev/null +++ b/Sources/Retry/Retryable/NotRetryable.swift @@ -0,0 +1,38 @@ +// MIT License +// +// Copyright © 2023 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. + +/// A concrete error type that is never retryable and wraps an underlying error. +/// +/// Throwing this error will prevent a retry. +/// +/// This wrapper type exists for the cases where ``RetryConfiguration/shouldRetry`` cannot make +/// a good decision (e.g. the underlying error type is not exposed by a library dependency). +public struct NotRetryable: Error { + let underlyingError: any Error + + /// Wraps the given error. + /// + /// - Parameter underlyingError: The error being wrapped. This will be the actual error thrown. + public init(_ underlyingError: any Error) { + self.underlyingError = underlyingError + } +} diff --git a/Sources/Retry/Retryable/Retryable.swift b/Sources/Retry/Retryable/Retryable.swift new file mode 100644 index 0000000..d270a1f --- /dev/null +++ b/Sources/Retry/Retryable/Retryable.swift @@ -0,0 +1,40 @@ +// MIT License +// +// Copyright © 2023 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. + +/// A concrete error type that is always retryable and wraps an underlying error. +/// +/// Throwing this error will always result in a retry, unless there are other conditions that make the failure +/// not retryable like reaching the maximum number of attempts. +/// +/// This wrapper type exists for the cases where ``RetryConfiguration/shouldRetry`` cannot make +/// a good decision (e.g. the underlying error type is not exposed by a library dependency). +public struct Retryable: Error { + let underlyingError: any Error + + /// Wraps the given error. + /// + /// - Parameter underlyingError: The error being wrapped. This will be the actual error thrown + /// if the failure is no longer retryable. + public init(_ underlyingError: any Error) { + self.underlyingError = underlyingError + } +} diff --git a/Tests/RetryTests/RetryTests.swift b/Tests/RetryTests/RetryTests.swift new file mode 100644 index 0000000..07e1ee1 --- /dev/null +++ b/Tests/RetryTests/RetryTests.swift @@ -0,0 +1,36 @@ +// MIT License +// +// Copyright © 2023 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 + +import XCTest + +// TODO: Add tests. +final class RetryTests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +}