diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 0000000..b573859 --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,42 @@ +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: 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..0023a53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + 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..092e503 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "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..da39b99 --- /dev/null +++ b/Package.swift @@ -0,0 +1,40 @@ +// 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: "http://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..c9384fe --- /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(of clockType: ClockType.Type) -> 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..239d2dd --- /dev/null +++ b/Sources/Retry/Backoff/Algorithms/FullJitterExponentialBackoff.swift @@ -0,0 +1,78 @@ +// 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(greaterThanOrEqualTo: Int.max / baseDelayInClockTicks) + if let maxDelay { + precondition(maxDelay >= baseDelay) + let maxDelayInClockTicks = Int((maxDelay / clockMinResolution).rounded()) + + maxExponent = min(Self.closestBaseTwoExponentOfValue(greaterThanOrEqualTo: maxDelayInClockTicks), + maxExponent) + } + self.maxExponent = maxExponent + } + + private static func closestBaseTwoExponentOfValue(greaterThanOrEqualTo value: Int) -> Int { + precondition(value >= 0) + + let bitWidth = Int.bitWidth + + let indexOfLeadingBit = bitWidth - value.leadingZeroBitCount - 1 + guard indexOfLeadingBit < bitWidth else { + return indexOfLeadingBit + } + + if value.nonzeroBitCount == 1 { + return indexOfLeadingBit + } else { + return indexOfLeadingBit + 1 + } + } + + mutating func nextDelay(of clockType: ClockType.Type) -> 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..22d75db --- /dev/null +++ b/Sources/Retry/Backoff/Backoff.swift @@ -0,0 +1,104 @@ +// 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: (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 (ClockType) -> any BackoffAlgorithm) { + self.makeAlgorithmClosure = makeAlgorithm + } + + // MARK: - Making the Algorithm + + func makeAlgorithm(clock: ClockType) -> any BackoffAlgorithm { + return makeAlgorithmClosure(clock) + } +} diff --git a/Sources/Retry/Backoff/BackoffAlgorithm.swift b/Sources/Retry/Backoff/BackoffAlgorithm.swift new file mode 100644 index 0000000..55bb3b3 --- /dev/null +++ b/Sources/Retry/Backoff/BackoffAlgorithm.swift @@ -0,0 +1,35 @@ +// 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( + of clockType: C.Type + ) -> C.Duration where C == ClockType +} diff --git a/Sources/Retry/Retry.docc/Retry.md b/Sources/Retry/Retry.docc/Retry.md new file mode 100644 index 0000000..c58fc6f --- /dev/null +++ b/Sources/Retry/Retry.docc/Retry.md @@ -0,0 +1,93 @@ +# ``Retry`` + +Retries with good default behavior and powerful flexibility. + +## Overview + +### Basic Usage + +```swift +extension MyError: Retryable { +} + +try await retry { + try await doSomething() // will retry if `MyError` is thrown +} +``` + +### Selectively Enabling Retries + +```swift +extension MyError: Retryable { + var isRetryable: Bool { + switch self { + case .myErrorCase: + return true + + case .myOtherErrorCase: + return false + } + } +} + +try await retry { + try await doSomething() // will retry if `MyError.myErrorCase` is thrown +} +``` + +### Enabling Retries on Private Error Types + +```swift +try await retry { + do { + try await doSomething() + } catch { + throw AlwaysRetryable(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 +let sharedRetryConfiguration = RetryConfiguration( + maxAttempts: 5, + clock: ContinuousClock(), + backoff: .default(baseDelay: .seconds(1), + maxDelay: nil), + logger: nil +) + +try await retry(with: sharedRetryConfiguration.withLogger(myLogger)) { + try await doSomething() +} +``` + +## Topics + +### Marking Errors as Retryable + +- ``Retryable`` +- ``AlwaysRetryable`` + +### Retrying Operations + +- ``retry(maxAttempts:backoff:appleLogger:logger:operation:)`` +- ``retry(with:operation:)`` + +### Configuring the Retry Behavior + +- ``RetryConfiguration`` +- ``Backoff`` +- ``BackoffAlgorithm`` diff --git a/Sources/Retry/Retry.swift b/Sources/Retry/Retry.swift new file mode 100644 index 0000000..0ea3f1b --- /dev/null +++ b/Sources/Retry/Retry.swift @@ -0,0 +1,168 @@ +// 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: +/// - The thrown error is not ``Retryable`` or ``Retryable/isRetryable-9lhv7`` returns `false`. +/// - The number of attempts reached the given maximum. +/// +/// - 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. +/// +/// - 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 +) async throws -> ReturnType { + var configuration = RetryConfiguration(maxAttempts: maxAttempts, + clock: ContinuousClock(), + backoff: backoff, + 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: +/// - The thrown error is not ``Retryable`` or ``Retryable/isRetryable-9lhv7`` returns `false`. +/// - The number of attempts reached the given maximum. +/// +/// - 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. +/// +/// - 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 +) async throws -> ReturnType { + let configuration = RetryConfiguration(maxAttempts: maxAttempts, + clock: ContinuousClock(), + backoff: backoff, + 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: +/// - The thrown error is not ``Retryable`` or ``Retryable/isRetryable-9lhv7`` returns `false`. +/// - 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 + + var attempt = 0 + while true { + let latestError: any Error + do { + return try await operation() + } catch { + if let error = error as? AlwaysRetryable { + latestError = error.underlyingError + } else { + latestError = 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 !latestError.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(of: ClockType.self) + + 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..f7a13c1 --- /dev/null +++ b/Sources/Retry/RetryConfiguration.swift @@ -0,0 +1,111 @@ +// 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 + +/// 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? + +#if canImport(OSLog) + public init(maxAttempts: Int?, + clock: ClockType, + backoff: Backoff, + appleLogger: os.Logger?) { + self.init(maxAttempts: maxAttempts, + clock: clock, + backoff: backoff, + logger: nil) + + self.appleLogger = appleLogger + } +#endif + + public init(maxAttempts: Int?, + clock: ClockType, + backoff: Backoff, + logger: Logging.Logger?) { + if let maxAttempts { + precondition(maxAttempts > 0) + } + + self.maxAttempts = maxAttempts + + self.clock = clock + self.backoff = backoff + + self.logger = logger + } + + 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 + } +} diff --git a/Sources/Retry/Retryable/AlwaysRetryable.swift b/Sources/Retry/Retryable/AlwaysRetryable.swift new file mode 100644 index 0000000..433f573 --- /dev/null +++ b/Sources/Retry/Retryable/AlwaysRetryable.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. + +/// 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 the underlying error type cannot be marked as +/// ``Retryable`` such as when the error type is not exposed by a library dependency. +/// +/// - Remark: For more convenience, prefer to add a ``Retryable`` conformance to error types +/// instead of using this wrapper type. +public struct AlwaysRetryable: Retryable { + let underlyingError: any Error + + /// Wraps the given error. + /// + /// - Parameter underlyingError: The error being wrapped. This will be the actual error thrown + /// when the failure is no longer retryable. + public init(_ underlyingError: any Error) { + self.underlyingError = underlyingError + } +} diff --git a/Sources/Retry/Retryable/Error+IsRetryable.swift b/Sources/Retry/Retryable/Error+IsRetryable.swift new file mode 100644 index 0000000..5a0eea8 --- /dev/null +++ b/Sources/Retry/Retryable/Error+IsRetryable.swift @@ -0,0 +1,31 @@ +// 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 isErrorRetryable: Bool { + if let retryable = self as? Retryable { + return retryable.isRetryable + } else { + return false + } + } +} diff --git a/Sources/Retry/Retryable/Retryable.swift b/Sources/Retry/Retryable/Retryable.swift new file mode 100644 index 0000000..5092429 --- /dev/null +++ b/Sources/Retry/Retryable/Retryable.swift @@ -0,0 +1,42 @@ +// 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. + +/// Indicates that the conforming error type may be retryable. +public protocol Retryable: Error { + /// Whether the error is retryable. + /// + /// For some error cases, there is a possibility that retrying might result in success (e.g. a client receiving an internal server error), + /// so the conforming type should return `true` to allow the operation to be retried. + /// + /// In other error cases, there is almost certainly no chance that retrying would result in a success (e.g. a client being told by a server + /// that its request was invalid), so the conforming type should return `false` to not waste time retrying the operation. + /// + /// - Returns: `true` if retrying is allowed for this error. + var isRetryable: Bool { get } +} + +public extension Retryable { + /// Returns `true` if not implemented by the conforming type. + var isRetryable: Bool { + return true + } +} 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 + } +}