From 751f7049311156902e2cca0a90386374656d56b1 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 | 19 + .../Advanced Use Cases/UseFakeClockType.swift | 79 ++++ Snippets/Common Use Cases/BasicUsage.swift | 16 + .../ConfigureRetryBehavior.swift | 22 ++ ...OrDisableRetriesForSpecificCodePaths.swift | 29 ++ ...rDisableRetriesForSpecificErrorCases.swift | 47 +++ .../ReuseRetryConfiguration.swift | 28 ++ .../Backoff/Algorithms/ConstantBackoff.swift | 33 ++ .../FullJitterExponentialBackoff.swift | 100 +++++ Sources/Retry/Backoff/Backoff.swift | 107 ++++++ Sources/Retry/Backoff/BackoffAlgorithm.swift | 33 ++ .../Retry/Retry.docc/Advanced Use Cases.md | 5 + Sources/Retry/Retry.docc/Common Use Cases.md | 21 + Sources/Retry/Retry.docc/Retry.md | 34 ++ Sources/Retry/Retry.swift | 358 ++++++++++++++++++ Sources/Retry/RetryConfiguration.swift | 137 +++++++ .../Retry/Retryable/Error+OriginalError.swift | 36 ++ Sources/Retry/Retryable/NotRetryable.swift | 38 ++ Sources/Retry/Retryable/Retryable.swift | 40 ++ Tests/RetryTests/ConstantBackoffTests.swift | 41 ++ .../Fakes/BackoffAlgorithmFake.swift | 43 +++ Tests/RetryTests/Fakes/ClockFake.swift | 81 ++++ Tests/RetryTests/Fakes/ErrorFake.swift | 24 ++ .../Fakes/RandomNumberGeneratorFake.swift | 45 +++ .../FullJitterExponentialBackoffTests.swift | 141 +++++++ Tests/RetryTests/RetryTests.swift | 166 ++++++++ 32 files changed, 1896 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 Snippets/Advanced Use Cases/UseFakeClockType.swift create mode 100644 Snippets/Common Use Cases/BasicUsage.swift create mode 100644 Snippets/Common Use Cases/ConfigureRetryBehavior.swift create mode 100644 Snippets/Common Use Cases/EnableOrDisableRetriesForSpecificCodePaths.swift create mode 100644 Snippets/Common Use Cases/EnableOrDisableRetriesForSpecificErrorCases.swift create mode 100644 Snippets/Common Use Cases/ReuseRetryConfiguration.swift 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/Advanced Use Cases.md create mode 100644 Sources/Retry/Retry.docc/Common Use Cases.md 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/Error+OriginalError.swift create mode 100644 Sources/Retry/Retryable/NotRetryable.swift create mode 100644 Sources/Retry/Retryable/Retryable.swift create mode 100644 Tests/RetryTests/ConstantBackoffTests.swift create mode 100644 Tests/RetryTests/Fakes/BackoffAlgorithmFake.swift create mode 100644 Tests/RetryTests/Fakes/ClockFake.swift create mode 100644 Tests/RetryTests/Fakes/ErrorFake.swift create mode 100644 Tests/RetryTests/Fakes/RandomNumberGeneratorFake.swift create mode 100644 Tests/RetryTests/FullJitterExponentialBackoffTests.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..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/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..8dba329 --- /dev/null +++ b/Package.swift @@ -0,0 +1,41 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "swift-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..7116e25 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# swift-retry + +Retries in Swift with sensible defaults 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) + +## Features + +- Designed for [Swift Concurrency](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/). +- Sensible defaults for behavior such as the choice of backoff algorithm, the maximum number of attempts, etc. +- Flexible enough for any use case. +- Comprehensive tests and documentation. + +## Usage + +See the [documentation](https://fumoboy007.github.io/swift-retry/documentation/retry/). diff --git a/Snippets/Advanced Use Cases/UseFakeClockType.swift b/Snippets/Advanced Use Cases/UseFakeClockType.swift new file mode 100644 index 0000000..b603c83 --- /dev/null +++ b/Snippets/Advanced Use Cases/UseFakeClockType.swift @@ -0,0 +1,79 @@ +// Use a fake `Clock` type for deterministic and efficient automated tests. + +// snippet.hide + +import Foundation +import Retry +import XCTest + +// snippet.show + +final class MyServiceImplementation where ClockType.Duration == Duration { + private let clock: ClockType + + init(clock: ClockType) { + self.clock = clock + } + + func doSomethingReliably() async throws { + try await retry(clock: clock) { + try await doSomething() + } + } +} + +final class MyServiceImplementationTests: XCTestCase { + func testDoSomethingReliably_succeeds() async throws { + let myService = MyServiceImplementation(clock: ClockFake()) + try await myService.doSomethingReliably() + } +} + +// snippet.hide + +class ClockFake: Clock, @unchecked Sendable { + typealias Instant = ContinuousClock.Instant + + private let realClock = ContinuousClock() + + private let lock = NSLock() + + private var _now: Instant + + init() { + self._now = realClock.now + } + + var now: Instant { + lock.lock() + defer { + lock.unlock() + } + + return _now + } + + var minimumResolution: Duration { + return realClock.minimumResolution + } + + 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) + } +} + +func doSomething() async throws { +} diff --git a/Snippets/Common Use Cases/BasicUsage.swift b/Snippets/Common Use Cases/BasicUsage.swift new file mode 100644 index 0000000..aa08074 --- /dev/null +++ b/Snippets/Common Use Cases/BasicUsage.swift @@ -0,0 +1,16 @@ +// Retry an operation using the default retry behavior. + +// snippet.hide + +import Retry + +// snippet.show + +try await retry { + try await doSomething() +} + +// snippet.hide + +func doSomething() async throws { +} diff --git a/Snippets/Common Use Cases/ConfigureRetryBehavior.swift b/Snippets/Common Use Cases/ConfigureRetryBehavior.swift new file mode 100644 index 0000000..707ee46 --- /dev/null +++ b/Snippets/Common Use Cases/ConfigureRetryBehavior.swift @@ -0,0 +1,22 @@ +// Configure the retry behavior. + +// snippet.hide + +import Logging +import Retry + +// snippet.show + +try await retry(maxAttempts: 5, + backoff: .default(baseDelay: .milliseconds(500), + maxDelay: .seconds(10)), + logger: myLogger) { + try await doSomething() +} + +// snippet.hide + +let myLogger = Logger(label: "Example Code") + +func doSomething() async throws { +} diff --git a/Snippets/Common Use Cases/EnableOrDisableRetriesForSpecificCodePaths.swift b/Snippets/Common Use Cases/EnableOrDisableRetriesForSpecificCodePaths.swift new file mode 100644 index 0000000..fe0fa87 --- /dev/null +++ b/Snippets/Common Use Cases/EnableOrDisableRetriesForSpecificCodePaths.swift @@ -0,0 +1,29 @@ +// Enable or disable retries based on which suboperation failed. + +// snippet.hide + +import Retry + +// snippet.show + +try await retry { + do { + try await doSomethingRetryable() + } catch { + throw Retryable(error) + } + + do { + try await doSomethingNotRetryable() + } catch { + throw NotRetryable(error) + } +} + +// snippet.hide + +func doSomethingRetryable() async throws { +} + +func doSomethingNotRetryable() async throws { +} diff --git a/Snippets/Common Use Cases/EnableOrDisableRetriesForSpecificErrorCases.swift b/Snippets/Common Use Cases/EnableOrDisableRetriesForSpecificErrorCases.swift new file mode 100644 index 0000000..402d8fa --- /dev/null +++ b/Snippets/Common Use Cases/EnableOrDisableRetriesForSpecificErrorCases.swift @@ -0,0 +1,47 @@ +// Specify which error cases are retryable. + +// snippet.hide + +import Retry + +// snippet.show + +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 + } + } +} + +// snippet.hide + +func doSomething() async throws { +} + +enum MyError: Error { + case myRetryableCase + case myNotRetryableCase +} diff --git a/Snippets/Common Use Cases/ReuseRetryConfiguration.swift b/Snippets/Common Use Cases/ReuseRetryConfiguration.swift new file mode 100644 index 0000000..d4a4e41 --- /dev/null +++ b/Snippets/Common Use Cases/ReuseRetryConfiguration.swift @@ -0,0 +1,28 @@ +// Encapsulate retry behavior in a ``RetryConfiguration`` instance. + +// snippet.hide + +import Logging +import Retry + +// snippet.show + +extension RetryConfiguration { + static let highTolerance = RetryConfiguration( + maxAttempts: 10, + backoff: .default(baseDelay: .seconds(1), + maxDelay: nil), + shouldRetry: { _ in true } + ) +} + +try await retry(with: .highTolerance.withLogger(myLogger)) { + try await doSomething() +} + +// snippet.hide + +let myLogger = Logger(label: "Example Code") + +func doSomething() async throws { +} 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..9f2bcd4 --- /dev/null +++ b/Sources/Retry/Backoff/Algorithms/FullJitterExponentialBackoff.swift @@ -0,0 +1,100 @@ +// 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 +where ClockType: Clock, RandomNumberGeneratorType: RandomNumberGenerator { + static var implicitMaxDelayInClockTicks: Int { + return Int.max + } + + private let clockMinResolution: ClockType.Duration + + private let baseDelayInClockTicks: Double + private let maxDelayInClockTicks: Double + + private let maxExponent: Int + + private var randomNumberGenerator: RandomNumberGeneratorType + + private var attempt = 0 + + init(clock: ClockType, + baseDelay: ClockType.Duration, + maxDelay: ClockType.Duration?, + randomNumberGenerator: RandomNumberGeneratorType) { + self.clockMinResolution = clock.minimumResolution + + self.baseDelayInClockTicks = baseDelay / clockMinResolution + precondition(baseDelayInClockTicks > 0) + + if let maxDelay { + precondition(maxDelay >= baseDelay) + self.maxDelayInClockTicks = min(maxDelay / clockMinResolution, + Double(Self.implicitMaxDelayInClockTicks)) + } else { + self.maxDelayInClockTicks = Double(Self.implicitMaxDelayInClockTicks) + } + + self.maxExponent = Self.closestBaseTwoExponentOfValue(greaterThanOrEqualTo: Int((maxDelayInClockTicks / baseDelayInClockTicks).rounded(.up))) + + self.randomNumberGenerator = randomNumberGenerator + } + + private static func closestBaseTwoExponentOfValue(greaterThanOrEqualTo value: Int) -> Int { + precondition(value >= 0) + + if value.nonzeroBitCount == 1 { + return Int.bitWidth - value.leadingZeroBitCount - 1 + } else { + return min(Int.bitWidth - value.leadingZeroBitCount, Int.bitWidth - 1) + } + } + + mutating func nextDelay() -> ClockType.Duration { + defer { + attempt += 1 + } + + // Limit the exponent to prevent the bit shift operation from overflowing. + let exponent = min(attempt, maxExponent) + let maxDelayInClockTicks = min(baseDelayInClockTicks * Double(1 << exponent), + maxDelayInClockTicks) + + let delayInClockTicks = Double.random(in: 0...maxDelayInClockTicks, + using: &randomNumberGenerator) + + // Unfortunately, `DurationProtocol` does not have a `Duration * Double` operator, so we need to cast to `Int`. + // We make sure to cast to `Int` at the end rather than at the beginning so that the imprecision is bounded. + return clockMinResolution * Int(clamping: UInt(delayInClockTicks.rounded())) + } +} + +extension FullJitterExponentialBackoff where RandomNumberGeneratorType == SystemRandomNumberGenerator { + init(clock: ClockType, + baseDelay: ClockType.Duration, + maxDelay: ClockType.Duration?) { + self.init(clock: clock, + baseDelay: baseDelay, + maxDelay: maxDelay, + randomNumberGenerator: SystemRandomNumberGenerator()) + } +} 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/Advanced Use Cases.md b/Sources/Retry/Retry.docc/Advanced Use Cases.md new file mode 100644 index 0000000..87d62fc --- /dev/null +++ b/Sources/Retry/Retry.docc/Advanced Use Cases.md @@ -0,0 +1,5 @@ +# Advanced Use Cases + +## Using a Fake Clock Type For Automated Tests + +@Snippet(path: "swift-retry/Snippets/Advanced Use Cases/UseFakeClockType") diff --git a/Sources/Retry/Retry.docc/Common Use Cases.md b/Sources/Retry/Retry.docc/Common Use Cases.md new file mode 100644 index 0000000..a5a33ea --- /dev/null +++ b/Sources/Retry/Retry.docc/Common Use Cases.md @@ -0,0 +1,21 @@ +# Common Use Cases + +## Basic Usage + +@Snippet(path: "swift-retry/Snippets/Common Use Cases/BasicUsage") + +## Enabling/Disabling Retries for Specific Error Cases + +@Snippet(path: "swift-retry/Snippets/Common Use Cases/EnableOrDisableRetriesForSpecificErrorCases") + +## Enabling/Disabling Retries for Specific Code Paths + +@Snippet(path: "swift-retry/Snippets/Common Use Cases/EnableOrDisableRetriesForSpecificCodePaths") + +## Configuring the Retry Behavior + +@Snippet(path: "swift-retry/Snippets/Common Use Cases/ConfigureRetryBehavior") + +## Reusing a Configuration + +@Snippet(path: "swift-retry/Snippets/Common Use Cases/ReuseRetryConfiguration") diff --git a/Sources/Retry/Retry.docc/Retry.md b/Sources/Retry/Retry.docc/Retry.md new file mode 100644 index 0000000..c4b4317 --- /dev/null +++ b/Sources/Retry/Retry.docc/Retry.md @@ -0,0 +1,34 @@ +# ``Retry`` + +Retries with sensible defaults and powerful flexibility. + +## Overview + +- Designed for [Swift Concurrency](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/). +- Sensible defaults for behavior such as the choice of backoff algorithm, the maximum number of attempts, etc. +- Flexible enough for any use case. + +## Topics + +### Examples + +- +- + +### Retrying Operations + +- ``retry(maxAttempts:backoff:appleLogger:logger:operation:shouldRetry:)`` +- ``retry(maxAttempts:clock:backoff:appleLogger:logger:operation:shouldRetry:)-2cjan`` +- ``retry(maxAttempts:clock:backoff:appleLogger:logger:operation:shouldRetry:)-2aiqm`` +- ``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..d17185d --- /dev/null +++ b/Sources/Retry/Retry.swift @@ -0,0 +1,358 @@ +// 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. +/// Sleeps in between attempts using `ContinuousClock`. +/// +/// 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 { + return try await retry(maxAttempts: maxAttempts, + clock: ContinuousClock(), + backoff: backoff, + appleLogger: appleLogger, + logger: logger, + operation: operation, + shouldRetry: shouldRetry) +} + +/// Attempts the given operation until it succeeds or until the failure is no longer retryable. +/// Sleeps in between attempts using the given clock whose duration type is the standard `Duration` type. +/// +/// 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`. +/// - clock: The clock that will be used to sleep in between attempts. +/// - 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, + clock: ClockType, + 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 where ClockType.Duration == Duration { + var configuration = RetryConfiguration(maxAttempts: maxAttempts, + clock: clock, + backoff: backoff, + shouldRetry: shouldRetry) + configuration.appleLogger = appleLogger + configuration.logger = logger + + return try await retry(with: configuration, + operation: operation) +} + +/// Attempts the given operation until it succeeds or until the failure is no longer retryable. +/// Sleeps in between attempts using the given clock. +/// +/// 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`. +/// - clock: The clock that will be used to sleep in between attempts. +/// - 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, + clock: ClockType, + backoff: Backoff, + 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: clock, + 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. +/// Sleeps in between attempts using `ContinuousClock`. +/// +/// 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 { + return try await retry(maxAttempts: maxAttempts, + clock: ContinuousClock(), + backoff: backoff, + logger: logger, + operation: operation, + shouldRetry: shouldRetry) +} + +/// Attempts the given operation until it succeeds or until the failure is no longer retryable. +/// Sleeps in between attempts using the given clock whose duration type is the standard `Duration` type. +/// +/// 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`. +/// - clock: The clock that will be used to sleep in between attempts. +/// - 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, + clock: ClockType, + 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 where ClockType.Duration == Duration { + var configuration = RetryConfiguration(maxAttempts: maxAttempts, + clock: clock, + backoff: backoff, + shouldRetry: shouldRetry) + configuration.logger = logger + + return try await retry(with: configuration, + operation: operation) +} + +/// Attempts the given operation until it succeeds or until the failure is no longer retryable. +/// Sleeps in between attempts using the given clock. +/// +/// 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`. +/// - clock: The clock that will be used to sleep in between attempts. +/// - 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, + clock: ClockType, + backoff: Backoff, + 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: clock, + 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 { + var latestError: any Error + let isErrorRetryable: Bool + + do { + return try await operation() + } catch { + switch error { + case let error as Retryable: + latestError = error + isErrorRetryable = true + + case let error as NotRetryable: + latestError = error + isErrorRetryable = false + + default: + latestError = error + isErrorRetryable = shouldRetry(error) + } + + latestError = latestError.originalError + } + + logger?[metadataKey: "retry.attempt"] = "\(attempt)" + // Only log the error type rather than the full error 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..5a08ecd --- /dev/null +++ b/Sources/Retry/RetryConfiguration.swift @@ -0,0 +1,137 @@ +// 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 { +} + +extension RetryConfiguration { + public init( + maxAttempts: Int?, + backoff: Backoff, + shouldRetry: @escaping @Sendable (any Error) -> Bool + ) where ClockType == ContinuousClock { + self.init(maxAttempts: maxAttempts, + clock: ContinuousClock(), + backoff: backoff, + shouldRetry: shouldRetry) + } + + public init( + maxAttempts: Int?, + backoff: Backoff, + shouldRetry: @escaping @Sendable (any Error) -> Bool + ) where ClockType == SuspendingClock { + self.init(maxAttempts: maxAttempts, + clock: SuspendingClock(), + backoff: backoff, + shouldRetry: shouldRetry) + } +} diff --git a/Sources/Retry/Retryable/Error+OriginalError.swift b/Sources/Retry/Retryable/Error+OriginalError.swift new file mode 100644 index 0000000..d58e838 --- /dev/null +++ b/Sources/Retry/Retryable/Error+OriginalError.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. + +extension Error { + var originalError: any Error { + switch self { + case let error as Retryable: + return error.underlyingError.originalError + + case let error as NotRetryable: + return error.underlyingError.originalError + + default: + return self + } + } +} 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/ConstantBackoffTests.swift b/Tests/RetryTests/ConstantBackoffTests.swift new file mode 100644 index 0000000..b368495 --- /dev/null +++ b/Tests/RetryTests/ConstantBackoffTests.swift @@ -0,0 +1,41 @@ +// 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. + +@testable import Retry + +import XCTest + +final class ConstantBackoffTests: XCTestCase { + func testIsConstant() { + for constantDelayInSeconds in 0..<3 { + let constantDelay = Duration.seconds(constantDelayInSeconds) + + let backoff = ConstantBackoff(delay: constantDelay) + + for _ in 0..<100 { + let delay = backoff.nextDelay() + + XCTAssertEqual(delay, constantDelay) + } + } + } +} diff --git a/Tests/RetryTests/Fakes/BackoffAlgorithmFake.swift b/Tests/RetryTests/Fakes/BackoffAlgorithmFake.swift new file mode 100644 index 0000000..7bc4adf --- /dev/null +++ b/Tests/RetryTests/Fakes/BackoffAlgorithmFake.swift @@ -0,0 +1,43 @@ +// 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 + +struct BackoffAlgorithmFake: BackoffAlgorithm { + private let clock: ClockType + + private var attempt = 0 + + init(clock: ClockType) { + self.clock = clock + } + + func nextDelay() -> ClockType.Duration { + return clock.minimumResolution * attempt + } + + static func delays(ofCount delayCount: Int, + for clock: ClockType) -> [ClockType.Duration] { + let algorithm = BackoffAlgorithmFake(clock: clock) + return (0.. UInt64 { + switch mode { + case .min: + // Add `1` to work around the following issue with Swift’s random number generator implementation: + // https://github.com/apple/swift/issues/70557 + return .min + 1 + + case .max: + return .max + } + } +} diff --git a/Tests/RetryTests/FullJitterExponentialBackoffTests.swift b/Tests/RetryTests/FullJitterExponentialBackoffTests.swift new file mode 100644 index 0000000..f773059 --- /dev/null +++ b/Tests/RetryTests/FullJitterExponentialBackoffTests.swift @@ -0,0 +1,141 @@ +// 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. + +@testable import Retry + +import XCTest + +final class FullJitterExponentialBackoffTests: XCTestCase { + private let clock = ContinuousClock() + + // MARK: - Tests + + func testIsExponentialWithFullJitter() { + let baseDelay = Duration.seconds(3) + + let randomNumberGeneratorFake = RandomNumberGeneratorFake(mode: .max) + var algorithm = FullJitterExponentialBackoff( + clock: clock, + baseDelay: baseDelay, + maxDelay: nil, + randomNumberGenerator: randomNumberGeneratorFake + ) + + let delay1 = algorithm.nextDelay() + assertEqualDelays(delay1, baseDelay) + + let delay2 = algorithm.nextDelay() + assertEqualDelays(delay2, baseDelay * 2) + + let delay3 = algorithm.nextDelay() + assertEqualDelays(delay3, baseDelay * 4) + + randomNumberGeneratorFake.mode = .min + let delay4 = algorithm.nextDelay() + assertEqualDelays(delay4, .zero) + + randomNumberGeneratorFake.mode = .max + let delay5 = algorithm.nextDelay() + assertEqualDelays(delay5, baseDelay * 16) + } + + func testMaxDelay_normalValue() { + let baseDelay = Duration.seconds(1) + let maxDelay = Duration.seconds(3) + + var algorithm = FullJitterExponentialBackoff( + clock: clock, + baseDelay: baseDelay, + maxDelay: maxDelay, + randomNumberGenerator: RandomNumberGeneratorFake(mode: .max) + ) + + let delay1 = algorithm.nextDelay() + assertEqualDelays(delay1, baseDelay) + + let delay2 = algorithm.nextDelay() + assertEqualDelays(delay2, baseDelay * 2) + + let delay3 = algorithm.nextDelay() + assertEqualDelays(delay3, maxDelay) + + let delay4 = algorithm.nextDelay() + assertEqualDelays(delay4, maxDelay) + } + + func testMaxDelay_extremeValue() { + let maxDelay = Duration(secondsComponent: .max, + attosecondsComponent: 0) + + var algorithm = FullJitterExponentialBackoff( + clock: clock, + baseDelay: .seconds(1), + maxDelay: maxDelay, + randomNumberGenerator: RandomNumberGeneratorFake(mode: .max) + ) + + let (maxDelaySecondsComponent, maxDelayAttosecondsComponent) = maxDelay.components + // Make sure the delay has increased to the max. + for _ in 0..<(maxDelaySecondsComponent.bitWidth + maxDelayAttosecondsComponent.bitWidth) { + _ = algorithm.nextDelay() + } + + let delay1 = algorithm.nextDelay() + XCTAssertLessThanOrEqual(delay1, maxDelay) + + let delay2 = algorithm.nextDelay() + XCTAssertEqual(delay2, delay1) + } + + func testMaxDelay_notSpecified_hasImplicitMaxDelay() { + var algorithm = FullJitterExponentialBackoff( + clock: clock, + baseDelay: .seconds(1), + maxDelay: nil, + randomNumberGenerator: RandomNumberGeneratorFake(mode: .max) + ) + + let implicitMaxDelayInClockTicks = type(of: algorithm).implicitMaxDelayInClockTicks + let implicitMaxDelay = clock.minimumResolution * implicitMaxDelayInClockTicks + + // Make sure the delay has increased to the max. + for _ in 0..! + + override func setUp() { + super.setUp() + + clockFake = ClockFake() + testingConfiguration = RetryConfiguration( + maxAttempts: Self.maxAttempts, + clock: clockFake, + backoff: Backoff { BackoffAlgorithmFake(clock: $0) }, + shouldRetry: { _ in true } + ) + } + + override func tearDown() { + clockFake = nil + testingConfiguration = nil + + super.tearDown() + } + + // MARK: - Tests + + func testNoFailure_successWithoutRetry() async throws { + try await retry(with: testingConfiguration) { + // Success. + } + + assertRetried(times: 0) + } + + func testOneFailure_successAfterRetry() async throws { + precondition(Self.maxAttempts > 1) + + var isFirstAttempt = true + + try await retry(with: testingConfiguration) { + if isFirstAttempt { + isFirstAttempt = false + + throw ErrorFake() + } else { + // Success. + } + } + + assertRetried(times: 1) + } + + func testAllAttemptsFail_failureAfterRetries() async throws { + try await assertThrowsErrorFake { + try await retry(with: testingConfiguration) { + throw ErrorFake() + } + } + + assertRetried(times: Self.maxAttempts - 1) + } + + func testFailure_shouldRetryReturnsFalse_failureWithoutRetry() async throws { + precondition(Self.maxAttempts > 1) + + try await assertThrowsErrorFake { + try await retry(with: testingConfiguration.withShouldRetry({ _ in false })) { + throw ErrorFake() + } + } + + assertRetried(times: 0) + } + + func testFailure_isNotRetryableError_failureWithoutRetry() async throws { + precondition(Self.maxAttempts > 1) + + try await assertThrowsErrorFake { + try await retry(with: testingConfiguration) { + throw NotRetryable(ErrorFake()) + } + } + + assertRetried(times: 0) + } + + func testOneFailure_isRetryableError_successAfterRetry() async throws { + precondition(Self.maxAttempts > 1) + + var isFirstAttempt = true + + try await retry(with: testingConfiguration.withShouldRetry({ _ in false })) { + if isFirstAttempt { + isFirstAttempt = false + + throw Retryable(ErrorFake()) + } else { + // Success. + } + } + + assertRetried(times: 1) + } + + func testAllAttemptsFail_latestErrorIsRetryableError_throwsOriginalError() async throws { + try await assertThrowsErrorFake { + try await retry(with: testingConfiguration) { + throw Retryable(NotRetryable(ErrorFake())) + } + } + + assertRetried(times: Self.maxAttempts - 1) + } + + func testFailure_errorIsNotRetryableError_throwsOriginalError() async throws { + try await assertThrowsErrorFake { + try await retry(with: testingConfiguration) { + throw NotRetryable(Retryable(ErrorFake())) + } + } + + assertRetried(times: 0) + } + + // MARK: - Assertions + + private func assertThrowsErrorFake(operation: () async throws -> Void) async throws { + do { + try await operation() + } catch is ErrorFake { + // Expected. + } + } + + private func assertRetried(times retryCount: Int) { + XCTAssertEqual(clockFake.allSleepDurations, + BackoffAlgorithmFake.delays(ofCount: retryCount, for: clockFake)) + } +}