├── .github └── workflows │ ├── documentation.yml │ └── tests.yml ├── .gitignore ├── .spi.yml ├── LICENSE.md ├── Package.resolved ├── Package.swift ├── README.md ├── Snippets ├── Advanced Use Cases │ ├── CustomBackoffAlgorithm.swift │ ├── EnforceMinDelay.swift │ ├── RetryableRequest.swift │ └── UseFakeClockType.swift └── Common Use Cases │ ├── BasicUsage.swift │ ├── ConfigureRetryBehavior.swift │ ├── EnableOrDisableRetriesForSpecificCodePaths.swift │ ├── EnableOrDisableRetriesForSpecificErrorCases.swift │ └── ReuseRetryConfiguration.swift ├── Sources └── Retry │ ├── Backoff │ ├── Algorithms │ │ ├── ConstantBackoff.swift │ │ ├── FullJitterExponentialBackoff.swift │ │ └── Random Number Generator │ │ │ ├── RandomNumberGenerator.swift │ │ │ └── StandardRandomNumberGenerator.swift │ ├── Backoff.swift │ └── BackoffAlgorithm.swift │ ├── Logger+RetryMetadataKey.swift │ ├── RecoveryAction.swift │ ├── Retry.docc │ ├── Advanced Use Cases.md │ ├── Common Use Cases.md │ └── Retry.md │ ├── Retry.swift │ ├── RetryConfiguration.swift │ ├── Retryable │ ├── Error+OriginalError.swift │ ├── NotRetryable.swift │ └── Retryable.swift │ └── RetryableRequest │ ├── RetryableRequest+SafeRetry.swift │ └── RetryableRequest.swift └── Tests └── RetryTests ├── ConstantBackoffTests.swift ├── Fakes ├── BackoffAlgorithmFake.swift ├── ClockFake.swift ├── ErrorFake.swift └── RandomNumberGeneratorFake.swift ├── FullJitterExponentialBackoffTests.swift └── RetryTests.swift /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 14 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 15 | concurrency: 16 | group: "pages" 17 | cancel-in-progress: false 18 | 19 | jobs: 20 | publish: 21 | name: Publish Documentation 22 | # TODO: Use `macos-latest` after the macOS 13 image graduates to GA. 23 | # https://github.com/actions/runner-images/issues/7508#issuecomment-1718206371 24 | runs-on: macos-13 25 | 26 | environment: 27 | name: github-pages 28 | url: ${{ steps.deployment.outputs.page_url }} 29 | 30 | steps: 31 | - name: Set up GitHub Pages 32 | uses: actions/configure-pages@v3 33 | - uses: maxim-lobanov/setup-xcode@v1 34 | with: 35 | xcode-version: latest-stable 36 | - name: Print Swift compiler version 37 | run: "swift --version" 38 | - uses: actions/checkout@v3 39 | - name: Generate documentation 40 | run: "swift package generate-documentation --target Retry --disable-indexing --include-extended-types --transform-for-static-hosting --hosting-base-path swift-retry" 41 | - name: Upload documentation 42 | uses: actions/upload-pages-artifact@v2 43 | with: 44 | path: ".build/plugins/Swift-DocC/outputs/Retry.doccarchive" 45 | - name: Deploy to GitHub Pages 46 | id: deployment 47 | uses: actions/deploy-pages@v2 48 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push] 4 | 5 | # TODO: Add Windows job after Swift is added to the Windows images [1] or after 6 | # `swift-actions/setup-swift` supports Swift 5.9+ on Windows [2]. 7 | # 1. https://github.com/actions/runner-images/issues/8281 8 | # 2. https://github.com/swift-actions/setup-swift/pull/470#issuecomment-1718406382 9 | jobs: 10 | test-macos: 11 | name: Run Tests on macOS 12 | # TODO: Use `macos-latest` after the macOS 13 image graduates to GA. 13 | # https://github.com/actions/runner-images/issues/7508#issuecomment-1718206371 14 | runs-on: macos-13 15 | 16 | steps: 17 | - uses: maxim-lobanov/setup-xcode@v1 18 | with: 19 | xcode-version: latest-stable 20 | - name: Print Swift compiler version 21 | run: "swift --version" 22 | - uses: actions/checkout@v3 23 | - name: Run tests 24 | run: "swift test --parallel" 25 | 26 | test-linux: 27 | name: Run Tests on Linux 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | - name: Print Swift compiler version 32 | run: "swift --version" 33 | - uses: actions/checkout@v3 34 | - name: Run tests 35 | run: "swift test --parallel" 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | external_links: 3 | documentation: "https://fumoboy007.github.io/swift-retry/documentation/retry/" 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright © 2023 Darren Mo. 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-docc-plugin", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/apple/swift-docc-plugin", 7 | "state" : { 8 | "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247", 9 | "version" : "1.3.0" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-docc-symbolkit", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/apple/swift-docc-symbolkit", 16 | "state" : { 17 | "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", 18 | "version" : "1.0.0" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-log", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/apple/swift-log.git", 25 | "state" : { 26 | "revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5", 27 | "version" : "1.5.4" 28 | } 29 | } 30 | ], 31 | "version" : 2 32 | } 33 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "swift-retry", 7 | platforms: [ 8 | .visionOS(.v1), 9 | .macOS(.v13), 10 | .macCatalyst(.v16), 11 | .iOS(.v16), 12 | .tvOS(.v16), 13 | .watchOS(.v9), 14 | ], 15 | products: [ 16 | .library( 17 | // TODO: Remove `DM` prefix after FB13180164 is resolved. The Xcode build system fails to build 18 | // a package graph that has duplicate product names. Other retry packages may also name their 19 | // library `Retry`, so we add a prefix to distinguish this package’s library. 20 | name: "DMRetry", 21 | targets: [ 22 | "Retry", 23 | ] 24 | ), 25 | ], 26 | dependencies: [ 27 | .package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.3.0"), 28 | .package(url: "https://github.com/apple/swift-log.git", from: "1.5.3"), 29 | ], 30 | targets: [ 31 | .target( 32 | name: "Retry", 33 | dependencies: [ 34 | .product(name: "Logging", package: "swift-log"), 35 | ] 36 | ), 37 | .testTarget( 38 | name: "RetryTests", 39 | dependencies: [ 40 | "Retry", 41 | ] 42 | ), 43 | ] 44 | ) 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # swift-retry 2 | 3 | Retries in Swift with sensible defaults and powerful flexibility. 4 | 5 | ![Swift 5.9](https://img.shields.io/badge/swift-v5.9-%23F05138) 6 | ![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) 7 | ![MIT License](https://img.shields.io/github/license/fumoboy007/swift-retry) 8 | ![Automated Tests Workflow Status](https://img.shields.io/github/actions/workflow/status/fumoboy007/swift-retry/tests.yml?event=push&label=tests) 9 | 10 | ## Basic Usage 11 | 12 | ```swift 13 | try await retry { 14 | try await doSomething() 15 | } 16 | ``` 17 | 18 | See the [documentation](https://fumoboy007.github.io/swift-retry/documentation/retry/) for examples of more advanced use cases. 19 | 20 | ## Overview 21 | 22 | ### Designed for Swift Concurrency 23 | 24 | The `retry` function is an `async` function that runs the given `async` closure repeatedly until it succeeds or until the failure is no longer retryable. The function sleeps in between attempts while respecting task cancellation. 25 | 26 | ### Sensible Defaults 27 | 28 | The library uses similar defaults as [Amazon Web Services](https://docs.aws.amazon.com/sdkref/latest/guide/feature-retry-behavior.html) and [Google Cloud](https://github.com/googleapis/gax-go/blob/465d35f180e8dc8b01979d09c780a10c41f15136/v2/call_option.go#L181-L205). 29 | 30 | An important but often overlooked default is the choice of backoff algorithm, which determines how long to sleep in between attempts. This library chooses an [exponential backoff](https://en.wikipedia.org/wiki/Exponential_backoff) algorithm by default, which is suitable for most 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 resource](https://cloud.google.com/blog/products/gcp/how-to-avoid-a-self-inflicted-ddos-attack-cre-life-lessons). 31 | 32 | ### Powerful Flexibility 33 | 34 | The API provides several customization points to accommodate any use case: 35 | - Retries can be selectively enabled or disabled for specific error cases by providing a custom `recoverFromFailure` closure. Retries can also be selectively enabled or disabled for specific code paths by wrapping thrown errors with `Retryable` or `NotRetryable`. 36 | - The `RetryConfiguration` type encapsulates the retry behavior so that it can be reused across multiple call sites without duplicating code. 37 | - The `Backoff` type represents the choice of algorithm that will be used to determine how long to sleep in between attempts. It has built-in support for common algorithms but can be initialized with a custom `BackoffAlgorithm` implementation if needed. 38 | - The clock that is used to sleep in between attempts can be replaced. For example, one might use a fake `Clock` implementation in automated tests to ensure the tests are deterministic and efficient. 39 | 40 | ### Safe Retries 41 | 42 | The module exposes a `RetryableRequest` protocol to add safe retry methods to a conforming request type. The retry methods in the protocol are similar to the top-level retry functions, but safer. The retry methods in the protocol enforce that the request is idempotent since it is unsafe to retry a non-idempotent request. 43 | 44 | To retry HTTP requests, consider using the [`swift-http-error-handling`](https://swiftpackageindex.com/fumoboy007/swift-http-error-handling) package, which adds `RetryableRequest` conformance to the standard `HTTPRequest` type. 45 | -------------------------------------------------------------------------------- /Snippets/Advanced Use Cases/CustomBackoffAlgorithm.swift: -------------------------------------------------------------------------------- 1 | // Implement and use a custom ``BackoffAlgorithm`` type. 2 | 3 | // snippet.hide 4 | 5 | import Retry 6 | 7 | // snippet.show 8 | 9 | struct MyBackoffAlgorithm: BackoffAlgorithm { 10 | private let clock: ClockType 11 | 12 | private var attempt = 0 13 | 14 | init(clock: ClockType) { 15 | self.clock = clock 16 | } 17 | 18 | mutating func nextDelay() -> ClockType.Duration { 19 | defer { 20 | attempt += 1 21 | } 22 | 23 | // Dummy algorithm for illustration. 24 | return clock.minimumResolution * attempt 25 | } 26 | } 27 | 28 | try await retry(backoff: Backoff { MyBackoffAlgorithm(clock: $0) }) { 29 | try await doSomething() 30 | } 31 | 32 | // snippet.hide 33 | 34 | func doSomething() async throws { 35 | } 36 | -------------------------------------------------------------------------------- /Snippets/Advanced Use Cases/EnforceMinDelay.swift: -------------------------------------------------------------------------------- 1 | // Sleep a minimum duration before the next attempt. 2 | 3 | // snippet.hide 4 | 5 | import Retry 6 | 7 | // snippet.show 8 | 9 | try await retry { 10 | try await doSomething() 11 | } recoverFromFailure: { error in 12 | switch error { 13 | case let error as MyRetryAwareServerError: 14 | return .retryAfter(ContinuousClock().now + error.minRetryDelay) 15 | 16 | default: 17 | return .retry 18 | } 19 | } 20 | 21 | // snippet.hide 22 | 23 | func doSomething() async throws { 24 | } 25 | 26 | struct MyRetryAwareServerError: Error { 27 | let minRetryDelay: Duration 28 | } 29 | -------------------------------------------------------------------------------- /Snippets/Advanced Use Cases/RetryableRequest.swift: -------------------------------------------------------------------------------- 1 | // Conform a request type to ``RetryableRequest`` to add safe retry methods to the request type. 2 | 3 | // snippet.hide 4 | 5 | import Retry 6 | 7 | // snippet.show 8 | 9 | extension MyRequest: RetryableRequest { 10 | var isIdempotent: Bool { 11 | // ... 12 | // snippet.hide 13 | return true 14 | // snippet.show 15 | } 16 | 17 | func unsafeRetryIgnoringIdempotency( 18 | with configuration: RetryConfiguration, 19 | @_inheritActorContext @_implicitSelfCapture operation: (Self) async throws -> ReturnType 20 | ) async throws -> ReturnType { 21 | // We can override the `recoverFromFailure` closure to automatically handle errors 22 | // specific to the communication protocol. 23 | let configuration = configuration.withRecoverFromFailure { error in 24 | switch error { 25 | case is MyTransientCommunicationError: 26 | return .retry 27 | 28 | case is MyNonTransientCommunicationError: 29 | return .throw 30 | 31 | default: 32 | return configuration.recoverFromFailure(error) 33 | } 34 | } 35 | 36 | return try await Retry.retry(with: configuration) { 37 | return try await operation(self) 38 | } 39 | } 40 | } 41 | 42 | // snippet.hide 43 | 44 | let myRequest = MyRequest() 45 | 46 | // snippet.show 47 | 48 | try await myRequest.retry { request in 49 | try await perform(request) 50 | } 51 | 52 | // snippet.hide 53 | 54 | struct MyRequest { 55 | } 56 | 57 | enum MyTransientCommunicationError: Error { 58 | } 59 | 60 | enum MyNonTransientCommunicationError: Error { 61 | } 62 | 63 | func perform(_ request: MyRequest) async throws { 64 | } 65 | -------------------------------------------------------------------------------- /Snippets/Advanced Use Cases/UseFakeClockType.swift: -------------------------------------------------------------------------------- 1 | // Use a fake `Clock` type for deterministic and efficient automated tests. 2 | 3 | // snippet.hide 4 | 5 | import Foundation 6 | import Retry 7 | import XCTest 8 | 9 | // snippet.show 10 | 11 | final class MyServiceImplementation where ClockType.Duration == Duration { 12 | private let clock: ClockType 13 | 14 | init(clock: ClockType) { 15 | self.clock = clock 16 | } 17 | 18 | func doSomethingReliably() async throws { 19 | try await retry(clock: clock) { 20 | try await doSomething() 21 | } 22 | } 23 | } 24 | 25 | final class MyServiceImplementationTests: XCTestCase { 26 | func testDoSomethingReliably_succeeds() async throws { 27 | let myService = MyServiceImplementation(clock: ClockFake()) 28 | try await myService.doSomethingReliably() 29 | } 30 | } 31 | 32 | // snippet.hide 33 | 34 | class ClockFake: Clock, @unchecked Sendable { 35 | typealias Instant = ContinuousClock.Instant 36 | 37 | private let lock = NSLock() 38 | 39 | init() { 40 | let realClock = ContinuousClock() 41 | self._now = realClock.now 42 | self.minimumResolution = realClock.minimumResolution 43 | } 44 | 45 | private var _now: Instant 46 | var now: Instant { 47 | lock.lock() 48 | defer { 49 | lock.unlock() 50 | } 51 | 52 | return _now 53 | } 54 | 55 | let minimumResolution: Duration 56 | 57 | func sleep(until deadline: Instant, 58 | tolerance: Duration?) async throws { 59 | // Refactored into a non-async method so that `NSLock.lock` and `NSLock.unlock` can be used. 60 | // Cannot use the async-safe `NSLock.withLocking` method until the following change is released: 61 | // https://github.com/apple/swift-corelibs-foundation/pull/4736 62 | sleep(until: deadline) 63 | } 64 | 65 | private func sleep(until deadline: Instant) { 66 | lock.lock() 67 | defer { 68 | lock.unlock() 69 | } 70 | 71 | _now = max(deadline, _now) 72 | } 73 | } 74 | 75 | func doSomething() async throws { 76 | } 77 | -------------------------------------------------------------------------------- /Snippets/Common Use Cases/BasicUsage.swift: -------------------------------------------------------------------------------- 1 | // Retry an operation using the default retry behavior. 2 | 3 | // snippet.hide 4 | 5 | import Retry 6 | 7 | // snippet.show 8 | 9 | try await retry { 10 | try await doSomething() 11 | } 12 | 13 | // snippet.hide 14 | 15 | func doSomething() async throws { 16 | } 17 | -------------------------------------------------------------------------------- /Snippets/Common Use Cases/ConfigureRetryBehavior.swift: -------------------------------------------------------------------------------- 1 | // Configure the retry behavior. 2 | 3 | // snippet.hide 4 | 5 | import Logging 6 | import Retry 7 | 8 | // snippet.show 9 | 10 | try await retry(maxAttempts: 5, 11 | backoff: .default(baseDelay: .milliseconds(500), 12 | maxDelay: .seconds(10)), 13 | logger: myLogger) { 14 | try await doSomething() 15 | } 16 | 17 | // snippet.hide 18 | 19 | let myLogger = Logger(label: "Example Code") 20 | 21 | func doSomething() async throws { 22 | } 23 | -------------------------------------------------------------------------------- /Snippets/Common Use Cases/EnableOrDisableRetriesForSpecificCodePaths.swift: -------------------------------------------------------------------------------- 1 | // Enable or disable retries based on which suboperation failed. 2 | 3 | // snippet.hide 4 | 5 | import Retry 6 | 7 | // snippet.show 8 | 9 | try await retry { 10 | do { 11 | try await doSomethingRetryable() 12 | } catch { 13 | throw Retryable(error) 14 | } 15 | 16 | do { 17 | try await doSomethingNotRetryable() 18 | } catch { 19 | throw NotRetryable(error) 20 | } 21 | } 22 | 23 | // snippet.hide 24 | 25 | func doSomethingRetryable() async throws { 26 | } 27 | 28 | func doSomethingNotRetryable() async throws { 29 | } 30 | -------------------------------------------------------------------------------- /Snippets/Common Use Cases/EnableOrDisableRetriesForSpecificErrorCases.swift: -------------------------------------------------------------------------------- 1 | // Specify which error cases are retryable. 2 | 3 | // snippet.hide 4 | 5 | import Retry 6 | 7 | // snippet.show 8 | 9 | try await retry { 10 | try await doSomething() 11 | } recoverFromFailure: { error in 12 | return error.isRetryable ? .retry : .throw 13 | } 14 | 15 | extension Error { 16 | var isRetryable: Bool { 17 | switch self { 18 | case let error as MyError: 19 | return error.isRetryable 20 | 21 | default: 22 | return true 23 | } 24 | } 25 | } 26 | 27 | extension MyError { 28 | var isRetryable: Bool { 29 | switch self { 30 | case .myRetryableCase: 31 | return true 32 | 33 | case .myNotRetryableCase: 34 | return false 35 | } 36 | } 37 | } 38 | 39 | // snippet.hide 40 | 41 | func doSomething() async throws { 42 | } 43 | 44 | enum MyError: Error { 45 | case myRetryableCase 46 | case myNotRetryableCase 47 | } 48 | -------------------------------------------------------------------------------- /Snippets/Common Use Cases/ReuseRetryConfiguration.swift: -------------------------------------------------------------------------------- 1 | // Encapsulate retry behavior in a ``RetryConfiguration`` instance. 2 | 3 | // snippet.hide 4 | 5 | import Logging 6 | import Retry 7 | 8 | // snippet.show 9 | 10 | extension RetryConfiguration where ClockType.Duration == Duration { 11 | static func standard(using clock: ClockType = ContinuousClock()) -> Self { 12 | return RetryConfiguration( 13 | clock: clock, 14 | recoverFromFailure: { $0.isRetryable ? .retry : .throw } 15 | ) 16 | } 17 | 18 | static func highTolerance(using clock: ClockType = ContinuousClock()) -> Self { 19 | return standard(using: clock) 20 | .withMaxAttempts(10) 21 | .withBackoff(.default(baseDelay: .seconds(1), 22 | maxDelay: nil)) 23 | } 24 | } 25 | 26 | try await retry(with: .highTolerance().withLogger(myLogger)) { 27 | try await doSomething() 28 | } 29 | 30 | // snippet.hide 31 | 32 | extension Error { 33 | var isRetryable: Bool { 34 | return true 35 | } 36 | } 37 | 38 | let myLogger = Logger(label: "Example Code") 39 | 40 | func doSomething() async throws { 41 | } 42 | -------------------------------------------------------------------------------- /Sources/Retry/Backoff/Algorithms/ConstantBackoff.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright © 2023 Darren Mo. 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | struct ConstantBackoff: BackoffAlgorithm { 24 | private let delay: ClockType.Duration 25 | 26 | init(delay: ClockType.Duration) { 27 | self.delay = delay 28 | } 29 | 30 | func nextDelay() -> ClockType.Duration { 31 | return delay 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/Retry/Backoff/Algorithms/FullJitterExponentialBackoff.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright © 2023 Darren Mo. 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | struct FullJitterExponentialBackoff: BackoffAlgorithm 24 | where ClockType: Clock, RandomNumberGeneratorType: RandomNumberGenerator { 25 | static var implicitMaxDelayInClockTicks: Int { 26 | return Int.max 27 | } 28 | 29 | private let clockMinResolution: ClockType.Duration 30 | 31 | private let baseDelayInClockTicks: Double 32 | private let maxDelayInClockTicks: Double 33 | 34 | private let maxExponent: Int 35 | 36 | private var randomNumberGenerator: RandomNumberGeneratorType 37 | 38 | private var attempt = 0 39 | 40 | init(clock: ClockType, 41 | baseDelay: ClockType.Duration, 42 | maxDelay: ClockType.Duration?, 43 | randomNumberGenerator: RandomNumberGeneratorType) { 44 | self.clockMinResolution = clock.minimumResolution 45 | 46 | self.baseDelayInClockTicks = baseDelay / clockMinResolution 47 | precondition(baseDelayInClockTicks > 0, "The base delay must be greater than zero.") 48 | 49 | if let maxDelay { 50 | precondition(maxDelay >= baseDelay, "The max delay must be greater than or equal to the base delay.") 51 | self.maxDelayInClockTicks = min(maxDelay / clockMinResolution, 52 | Double(Self.implicitMaxDelayInClockTicks)) 53 | } else { 54 | self.maxDelayInClockTicks = Double(Self.implicitMaxDelayInClockTicks) 55 | } 56 | 57 | self.maxExponent = Self.closestBaseTwoExponentOfValue(greaterThanOrEqualTo: Int((maxDelayInClockTicks / baseDelayInClockTicks).rounded(.up))) 58 | 59 | self.randomNumberGenerator = randomNumberGenerator 60 | } 61 | 62 | private static func closestBaseTwoExponentOfValue(greaterThanOrEqualTo value: Int) -> Int { 63 | precondition(value >= 0) 64 | 65 | if value.nonzeroBitCount == 1 { 66 | return Int.bitWidth - value.leadingZeroBitCount - 1 67 | } else { 68 | return min(Int.bitWidth - value.leadingZeroBitCount, Int.bitWidth - 1) 69 | } 70 | } 71 | 72 | mutating func nextDelay() -> ClockType.Duration { 73 | defer { 74 | attempt += 1 75 | } 76 | 77 | // Limit the exponent to prevent the bit shift operation from overflowing. 78 | let exponent = min(attempt, maxExponent) 79 | let maxDelayInClockTicks = min(baseDelayInClockTicks * Double(1 << exponent), 80 | maxDelayInClockTicks) 81 | 82 | let delayInClockTicks = randomNumberGenerator.random(in: 0...maxDelayInClockTicks) 83 | 84 | // Unfortunately, `DurationProtocol` does not have a `Duration * Double` operator, so we need to cast to `Int`. 85 | // We make sure to cast to `Int` at the end rather than at the beginning so that the imprecision is bounded. 86 | return clockMinResolution * Int(clamping: UInt(delayInClockTicks.rounded())) 87 | } 88 | } 89 | 90 | extension FullJitterExponentialBackoff where RandomNumberGeneratorType == StandardRandomNumberGenerator { 91 | init(clock: ClockType, 92 | baseDelay: ClockType.Duration, 93 | maxDelay: ClockType.Duration?) { 94 | self.init(clock: clock, 95 | baseDelay: baseDelay, 96 | maxDelay: maxDelay, 97 | randomNumberGenerator: StandardRandomNumberGenerator()) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Sources/Retry/Backoff/Algorithms/Random Number Generator/RandomNumberGenerator.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright © 2023 Darren Mo. 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | /// A protocol that allows one to specify a different implementation from the standard one (e.g. for automated tests). 24 | /// 25 | /// - Remark: Cannot use the Swift standard library’s `RandomNumberGenerator` protocol for this purpose as detailed here: 26 | /// https://github.com/apple/swift/issues/70557 27 | protocol RandomNumberGenerator { 28 | func random( 29 | in range: ClosedRange 30 | ) -> T where T: BinaryFloatingPoint, T.RawSignificand: FixedWidthInteger 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Retry/Backoff/Algorithms/Random Number Generator/StandardRandomNumberGenerator.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright © 2023 Darren Mo. 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | struct StandardRandomNumberGenerator: RandomNumberGenerator { 24 | func random( 25 | in range: ClosedRange 26 | ) -> T where T: BinaryFloatingPoint, T.RawSignificand: FixedWidthInteger { 27 | return T.random(in: range) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Retry/Backoff/Backoff.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright © 2023 Darren Mo. 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | /// The choice of algorithm that will be used to determine how long to sleep in between attempts. 24 | public struct Backoff { 25 | // MARK: - Built-In Algorithms 26 | 27 | /// The default algorithm, which is suitable for most use cases. 28 | /// 29 | /// This algorithm is an [exponential backoff](https://en.wikipedia.org/wiki/Exponential_backoff) algorithm. 30 | /// The specific choice of algorithm is an implementation detail, which may change in the future. 31 | /// 32 | /// - Parameters: 33 | /// - baseDelay: A duration that all delays will be based on. For example, in a simple exponential 34 | /// backoff algorithm, the first delay might be `baseDelay`, the second delay might be 35 | /// `baseDelay * 2`, the third delay might be `baseDelay * 2 * 2`, and so on. 36 | /// - maxDelay: The desired maximum duration in between attempts. There may also be a maximum 37 | /// enforced by the algorithm implementation. 38 | public static func `default`(baseDelay: ClockType.Duration, 39 | maxDelay: ClockType.Duration?) -> Self { 40 | return exponentialWithFullJitter(baseDelay: baseDelay, 41 | maxDelay: maxDelay) 42 | } 43 | 44 | /// Exponential backoff with “full jitter”. 45 | /// 46 | /// This algorithm is used by [AWS](https://docs.aws.amazon.com/sdkref/latest/guide/feature-retry-behavior.html) and 47 | /// [Google Cloud](https://github.com/googleapis/gax-go/blob/465d35f180e8dc8b01979d09c780a10c41f15136/v2/call_option.go#L181-L205), 48 | /// 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/) 49 | /// by AWS. 50 | /// 51 | /// - Parameters: 52 | /// - baseDelay: A duration that all delays will be based on. For example, in a simple exponential 53 | /// backoff algorithm, the first delay might be `baseDelay`, the second delay might be 54 | /// `baseDelay * 2`, the third delay might be `baseDelay * 2 * 2`, and so on. 55 | /// - maxDelay: The desired maximum duration in between attempts. There may also be a maximum 56 | /// enforced by the algorithm implementation. 57 | /// 58 | /// - SeeAlso: ``default(baseDelay:maxDelay:)`` 59 | public static func exponentialWithFullJitter(baseDelay: ClockType.Duration, 60 | maxDelay: ClockType.Duration?) -> Self { 61 | return Self { clock in 62 | return FullJitterExponentialBackoff(clock: clock, 63 | baseDelay: baseDelay, 64 | maxDelay: maxDelay) 65 | } 66 | } 67 | 68 | /// Constant delay. 69 | /// 70 | /// - Warning: This algorithm should only be used as an optimization for a small set of use cases. 71 | /// Most retry use cases involve a resource, such as a server, with potentially many clients where an 72 | /// exponential backoff algorithm would be ideal to avoid [DDoSing the resource](https://cloud.google.com/blog/products/gcp/how-to-avoid-a-self-inflicted-ddos-attack-cre-life-lessons). 73 | /// The constant delay algorithm should only be used in cases where there is no possibility of a DDoS. 74 | /// 75 | /// - Parameter delay: The constant duration to sleep in between attempts. 76 | /// 77 | /// - SeeAlso: ``default(baseDelay:maxDelay:)`` 78 | public static func constant(_ delay: ClockType.Duration) -> Self { 79 | return Self { _ in 80 | return ConstantBackoff(delay: delay) 81 | } 82 | } 83 | 84 | // MARK: - Private Properties 85 | 86 | private let makeAlgorithmClosure: @Sendable (ClockType) -> any BackoffAlgorithm 87 | 88 | // MARK: - Initialization 89 | 90 | /// Initializes the instance with a specific algorithm. 91 | /// 92 | /// - Parameter makeAlgorithm: A closure that returns a ``BackoffAlgorithm`` implementation. 93 | /// 94 | /// - SeeAlso: ``default(baseDelay:maxDelay:)`` 95 | public init(makeAlgorithm: @escaping @Sendable (ClockType) -> any BackoffAlgorithm) { 96 | self.makeAlgorithmClosure = makeAlgorithm 97 | } 98 | 99 | // MARK: - Making the Algorithm 100 | 101 | func makeAlgorithm(clock: ClockType) -> any BackoffAlgorithm { 102 | return makeAlgorithmClosure(clock) 103 | } 104 | } 105 | 106 | extension Backoff: Sendable { 107 | } 108 | -------------------------------------------------------------------------------- /Sources/Retry/Backoff/BackoffAlgorithm.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright © 2023 Darren Mo. 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | /// Determines how long to sleep in between attempts. 24 | /// 25 | /// Implement a custom algorithm by implementing a type that conforms to this protocol. 26 | /// Use the custom algorithm by passing a closure that returns an instance of that type 27 | /// to ``Backoff/init(makeAlgorithm:)``. 28 | public protocol BackoffAlgorithm { 29 | associatedtype ClockType: Clock 30 | 31 | /// Determines the delay before the next attempt. 32 | mutating func nextDelay() -> ClockType.Duration 33 | } 34 | -------------------------------------------------------------------------------- /Sources/Retry/Logger+RetryMetadataKey.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright © 2024 Darren Mo. 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Logging 24 | 25 | extension Logger { 26 | /// The metadata keys used by the retry implementation. 27 | public enum RetryMetadataKey: String { 28 | /// The maximum number of attempts that are allowed. 29 | /// 30 | /// The key will be absent if there is no configured maximum. 31 | case maxAttempts = "retry.configuration.max_attempts" 32 | 33 | /// The one-based attempt number. 34 | case attemptNumber = "retry.attempt" 35 | 36 | /// The error that caused the attempt to fail. 37 | /// 38 | /// This is the original error after removing wrapper types like ``Retryable`` and ``NotRetryable``. 39 | /// 40 | /// - Warning: The error may contain private information. Consider redacting the metadata value later in 41 | /// the logging pipeline before the log message is persisted or transmitted. The less sensitive 42 | /// ``errorType`` metadata value may be sufficient. 43 | case error = "retry.error" 44 | 45 | /// The Swift type of the error that caused the attempt to fail. 46 | /// 47 | /// This is the original error after removing wrapper types like ``Retryable`` and ``NotRetryable``. 48 | /// 49 | /// - SeeAlso: ``error`` 50 | case errorType = "retry.error.type" 51 | 52 | /// The delay before the next attempt. 53 | /// 54 | /// The metadata value format is an implementation detail of the Swift standard library, so it may change 55 | /// when the Swift version changes. 56 | case retryDelay = "retry.delay" 57 | 58 | /// The minimum delay before the next attempt, as requested via ``RecoveryAction/retryAfter(_:)``. 59 | /// 60 | /// The metadata value format is an implementation detail of the Swift standard library, so it may change 61 | /// when the Swift version changes. 62 | case requestedMinRetryDelay = "retry.after" 63 | } 64 | } 65 | 66 | extension Logger { 67 | subscript(metadataKey metadataKey: RetryMetadataKey) -> Metadata.Value? { 68 | get { 69 | return self[metadataKey: metadataKey.rawValue] 70 | } 71 | 72 | set { 73 | self[metadataKey: metadataKey.rawValue] = newValue 74 | } 75 | } 76 | } 77 | 78 | extension Logger { 79 | func trace(_ message: @autoclosure () -> Logger.Message, 80 | metadata: @autoclosure () -> [RetryMetadataKey: MetadataValue]? = nil, 81 | source: @autoclosure () -> String? = nil, 82 | file: String = #fileID, 83 | function: String = #function, 84 | line: UInt = #line) { 85 | log(level: .trace, 86 | message(), 87 | metadata: metadata(), 88 | source: source(), 89 | file: file, 90 | function: function, 91 | line: line) 92 | } 93 | 94 | func debug(_ message: @autoclosure () -> Logger.Message, 95 | metadata: @autoclosure () -> [RetryMetadataKey: MetadataValue]? = nil, 96 | source: @autoclosure () -> String? = nil, 97 | file: String = #fileID, 98 | function: String = #function, 99 | line: UInt = #line) { 100 | log(level: .debug, 101 | message(), 102 | metadata: metadata(), 103 | source: source(), 104 | file: file, 105 | function: function, 106 | line: line) 107 | } 108 | 109 | func info(_ message: @autoclosure () -> Logger.Message, 110 | metadata: @autoclosure () -> [RetryMetadataKey: MetadataValue]? = nil, 111 | source: @autoclosure () -> String? = nil, 112 | file: String = #fileID, 113 | function: String = #function, 114 | line: UInt = #line) { 115 | log(level: .info, 116 | message(), 117 | metadata: metadata(), 118 | source: source(), 119 | file: file, 120 | function: function, 121 | line: line) 122 | } 123 | 124 | func notice(_ message: @autoclosure () -> Logger.Message, 125 | metadata: @autoclosure () -> [RetryMetadataKey: MetadataValue]? = nil, 126 | source: @autoclosure () -> String? = nil, 127 | file: String = #fileID, 128 | function: String = #function, 129 | line: UInt = #line) { 130 | log(level: .notice, 131 | message(), 132 | metadata: metadata(), 133 | source: source(), 134 | file: file, 135 | function: function, 136 | line: line) 137 | } 138 | 139 | func warning(_ message: @autoclosure () -> Logger.Message, 140 | metadata: @autoclosure () -> [RetryMetadataKey: MetadataValue]? = nil, 141 | source: @autoclosure () -> String? = nil, 142 | file: String = #fileID, 143 | function: String = #function, 144 | line: UInt = #line) { 145 | log(level: .warning, 146 | message(), 147 | metadata: metadata(), 148 | source: source(), 149 | file: file, 150 | function: function, 151 | line: line) 152 | } 153 | 154 | func error(_ message: @autoclosure () -> Logger.Message, 155 | metadata: @autoclosure () -> [RetryMetadataKey: MetadataValue]? = nil, 156 | source: @autoclosure () -> String? = nil, 157 | file: String = #fileID, 158 | function: String = #function, 159 | line: UInt = #line) { 160 | log(level: .error, 161 | message(), 162 | metadata: metadata(), 163 | source: source(), 164 | file: file, 165 | function: function, 166 | line: line) 167 | } 168 | 169 | func critical(_ message: @autoclosure () -> Logger.Message, 170 | metadata: @autoclosure () -> [RetryMetadataKey: MetadataValue]? = nil, 171 | source: @autoclosure () -> String? = nil, 172 | file: String = #fileID, 173 | function: String = #function, 174 | line: UInt = #line) { 175 | log(level: .critical, 176 | message(), 177 | metadata: metadata(), 178 | source: source(), 179 | file: file, 180 | function: function, 181 | line: line) 182 | } 183 | 184 | func log(level: Level, 185 | _ message: @autoclosure () -> Message, 186 | metadata: @autoclosure () -> [RetryMetadataKey: MetadataValue]? = nil, 187 | source: @autoclosure () -> String? = nil, 188 | file: String = #fileID, 189 | function: String = #function, 190 | line: UInt = #line) { 191 | log(level: level, 192 | message(), 193 | metadata: Self.transform(metadata()), 194 | source: source(), 195 | file: file, 196 | function: function, 197 | line: line) 198 | } 199 | 200 | private static func transform(_ metadata: [RetryMetadataKey: MetadataValue]?) -> Metadata? { 201 | guard let metadata else { 202 | return nil 203 | } 204 | 205 | return Dictionary(uniqueKeysWithValues: metadata.lazy.map { ($0.key.rawValue, $0.value) }) 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /Sources/Retry/RecoveryAction.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright © 2024 Darren Mo. 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | /// The action to take after an attempt fails. 24 | public enum RecoveryAction { 25 | /// Retries the operation, unless the number of attempts reached ``RetryConfiguration/maxAttempts``. 26 | case retry 27 | 28 | /// Retries the operation only after the given instant in time has been reached. 29 | /// 30 | /// For example, an HTTP server may send a `Retry-After` header in its response, which indicates 31 | /// to the client that the request should not be retried until after a minimum amount of time has passed. 32 | /// This recovery action can be used for such a use case. 33 | /// 34 | /// It is not guaranteed that the operation will be retried. The backoff process continues until the given 35 | /// instant in time has been reached, incrementing the number of attempts as usual. The operation will 36 | /// be retried only if the number of attempts has not reached ``RetryConfiguration/maxAttempts``. 37 | case retryAfter(ClockType.Instant) 38 | 39 | /// Throws the error without retrying the operation. 40 | case `throw` 41 | } 42 | -------------------------------------------------------------------------------- /Sources/Retry/Retry.docc/Advanced Use Cases.md: -------------------------------------------------------------------------------- 1 | # Advanced Use Cases 2 | 3 | ## Using a Fake Clock Type For Automated Tests 4 | 5 | @Snippet(path: "swift-retry/Snippets/Advanced Use Cases/UseFakeClockType") 6 | 7 | ## Implementing a Custom Backoff Algorithm 8 | 9 | @Snippet(path: "swift-retry/Snippets/Advanced Use Cases/CustomBackoffAlgorithm") 10 | 11 | ## Adding Safe Retry Methods to a Request Type 12 | 13 | @Snippet(path: "swift-retry/Snippets/Advanced Use Cases/RetryableRequest") 14 | 15 | ## Enforcing a Minimum Delay Before Retrying 16 | 17 | @Snippet(path: "swift-retry/Snippets/Advanced Use Cases/EnforceMinDelay") 18 | -------------------------------------------------------------------------------- /Sources/Retry/Retry.docc/Common Use Cases.md: -------------------------------------------------------------------------------- 1 | # Common Use Cases 2 | 3 | ## Basic Usage 4 | 5 | @Snippet(path: "swift-retry/Snippets/Common Use Cases/BasicUsage") 6 | 7 | ## Enabling/Disabling Retries for Specific Error Cases 8 | 9 | @Snippet(path: "swift-retry/Snippets/Common Use Cases/EnableOrDisableRetriesForSpecificErrorCases") 10 | 11 | ## Enabling/Disabling Retries for Specific Code Paths 12 | 13 | @Snippet(path: "swift-retry/Snippets/Common Use Cases/EnableOrDisableRetriesForSpecificCodePaths") 14 | 15 | ## Configuring the Retry Behavior 16 | 17 | @Snippet(path: "swift-retry/Snippets/Common Use Cases/ConfigureRetryBehavior") 18 | 19 | ## Reusing a Configuration 20 | 21 | @Snippet(path: "swift-retry/Snippets/Common Use Cases/ReuseRetryConfiguration") 22 | -------------------------------------------------------------------------------- /Sources/Retry/Retry.docc/Retry.md: -------------------------------------------------------------------------------- 1 | # ``Retry`` 2 | 3 | Retries with sensible defaults and powerful flexibility. 4 | 5 | ## Overview 6 | 7 | ### Designed for Swift Concurrency 8 | 9 | The ``retry(maxAttempts:backoff:appleLogger:logger:operation:recoverFromFailure:)`` function is an `async` function that runs the given `async` closure repeatedly until it succeeds or until the failure is no longer retryable. The function sleeps in between attempts while respecting task cancellation. 10 | 11 | ### Sensible Defaults 12 | 13 | The library uses similar defaults as [Amazon Web Services](https://docs.aws.amazon.com/sdkref/latest/guide/feature-retry-behavior.html) and [Google Cloud](https://github.com/googleapis/gax-go/blob/465d35f180e8dc8b01979d09c780a10c41f15136/v2/call_option.go#L181-L205). 14 | 15 | An important but often overlooked default is the choice of backoff algorithm, which determines how long to sleep in between attempts. This library chooses an [exponential backoff](https://en.wikipedia.org/wiki/Exponential_backoff) algorithm by default, which is suitable for most 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 resource](https://cloud.google.com/blog/products/gcp/how-to-avoid-a-self-inflicted-ddos-attack-cre-life-lessons). 16 | 17 | ### Powerful Flexibility 18 | 19 | The API provides several customization points to accommodate any use case: 20 | - Retries can be selectively enabled or disabled for specific error cases by providing a custom ``RetryConfiguration/recoverFromFailure`` closure. Retries can also be selectively enabled or disabled for specific code paths by wrapping thrown errors with ``Retryable`` or ``NotRetryable``. 21 | - The ``RetryConfiguration`` type encapsulates the retry behavior so that it can be reused across multiple call sites without duplicating code. 22 | - The ``Backoff`` type represents the choice of algorithm that will be used to determine how long to sleep in between attempts. It has built-in support for common algorithms but can be initialized with a custom ``BackoffAlgorithm`` implementation if needed. 23 | - The ``RetryConfiguration/clock`` that is used to sleep in between attempts can be replaced. For example, one might use a fake `Clock` implementation in automated tests to ensure the tests are deterministic and efficient. 24 | 25 | ### Safe Retries 26 | 27 | The module exposes a ``RetryableRequest`` protocol to add safe retry methods to a conforming request type. The retry methods in the protocol are similar to the top-level retry functions, but safer. The retry methods in the protocol enforce that the request is idempotent since it is unsafe to retry a non-idempotent request. 28 | 29 | To retry HTTP requests, consider using the [`swift-http-error-handling`](https://swiftpackageindex.com/fumoboy007/swift-http-error-handling) package, which adds ``RetryableRequest`` conformance to the standard `HTTPRequest` type. 30 | 31 | ## Topics 32 | 33 | ### Examples 34 | 35 | - 36 | - 37 | 38 | ### Retrying Operations 39 | 40 | - ``retry(maxAttempts:backoff:appleLogger:logger:operation:recoverFromFailure:)`` 41 | - ``retry(maxAttempts:clock:backoff:appleLogger:logger:operation:recoverFromFailure:)-6s251`` 42 | - ``retry(maxAttempts:clock:backoff:appleLogger:logger:operation:recoverFromFailure:)-2e9va`` 43 | - ``retry(with:operation:)`` 44 | 45 | ### Configuring the Retry Behavior 46 | 47 | - ``RetryConfiguration`` 48 | - ``RecoveryAction`` 49 | - ``Backoff`` 50 | - ``BackoffAlgorithm`` 51 | 52 | ### Enabling/Disabling Retries for Specific Code Paths 53 | 54 | - ``Retryable`` 55 | - ``NotRetryable`` 56 | 57 | ### Safely Retrying Requests 58 | 59 | - ``RetryableRequest`` 60 | -------------------------------------------------------------------------------- /Sources/Retry/Retry.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright © 2023–2024 Darren Mo. 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Logging 24 | #if canImport(OSLog) 25 | import OSLog 26 | #endif 27 | 28 | #if canImport(OSLog) 29 | /// Attempts the given operation until it succeeds or until the failure is no longer retryable. 30 | /// Sleeps in between attempts using `ContinuousClock`. 31 | /// 32 | /// Failures may not be retryable for the following reasons: 33 | /// - `recoverFromFailure` returns ``RecoveryAction/throw``. 34 | /// - The thrown error is ``NotRetryable``. 35 | /// - The number of attempts reached `maxAttempts`. 36 | /// 37 | /// - Parameters: 38 | /// - maxAttempts: The maximum number of times to attempt the operation. Must be greater than `0`. 39 | /// - backoff: The choice of algorithm that will be used to determine how long to sleep in between attempts. 40 | /// - appleLogger: The logger that will be used to log a message when an attempt fails. The function will 41 | /// log messages using the `debug` log level. 42 | /// - logger: The logger that will be used to log a message when an attempt fails. The function will log 43 | /// messages using the `debug` log level. Consider using `appleLogger` when possible. 44 | /// - operation: The operation to attempt. 45 | /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. 46 | /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. 47 | /// 48 | /// - SeeAlso: ``retry(with:operation:)`` 49 | /// - SeeAlso: ``RetryableRequest`` 50 | public func retry( 51 | maxAttempts: Int? = 3, 52 | backoff: Backoff = .default(baseDelay: .seconds(1), maxDelay: .seconds(20)), 53 | appleLogger: os.Logger? = nil, 54 | logger: Logging.Logger? = nil, 55 | @_inheritActorContext @_implicitSelfCapture operation: () async throws -> ReturnType, 56 | recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } 57 | ) async throws -> ReturnType { 58 | return try await retry(maxAttempts: maxAttempts, 59 | clock: ContinuousClock(), 60 | backoff: backoff, 61 | appleLogger: appleLogger, 62 | logger: logger, 63 | operation: operation, 64 | recoverFromFailure: recoverFromFailure) 65 | } 66 | 67 | /// Attempts the given operation until it succeeds or until the failure is no longer retryable. 68 | /// Sleeps in between attempts using the given clock whose duration type is the standard `Duration` type. 69 | /// 70 | /// Failures may not be retryable for the following reasons: 71 | /// - `recoverFromFailure` returns ``RecoveryAction/throw``. 72 | /// - The thrown error is ``NotRetryable``. 73 | /// - The number of attempts reached `maxAttempts`. 74 | /// 75 | /// - Parameters: 76 | /// - maxAttempts: The maximum number of times to attempt the operation. Must be greater than `0`. 77 | /// - clock: The clock that will be used to sleep in between attempts. 78 | /// - backoff: The choice of algorithm that will be used to determine how long to sleep in between attempts. 79 | /// - appleLogger: The logger that will be used to log a message when an attempt fails. The function will 80 | /// log messages using the `debug` log level. 81 | /// - logger: The logger that will be used to log a message when an attempt fails. The function will log 82 | /// messages using the `debug` log level. Consider using `appleLogger` when possible. 83 | /// - operation: The operation to attempt. 84 | /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. 85 | /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. 86 | /// 87 | /// - SeeAlso: ``retry(with:operation:)`` 88 | /// - SeeAlso: ``RetryableRequest`` 89 | public func retry( 90 | maxAttempts: Int? = 3, 91 | clock: ClockType, 92 | backoff: Backoff = .default(baseDelay: .seconds(1), maxDelay: .seconds(20)), 93 | appleLogger: os.Logger? = nil, 94 | logger: Logging.Logger? = nil, 95 | @_inheritActorContext @_implicitSelfCapture operation: () async throws -> ReturnType, 96 | recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } 97 | ) async throws -> ReturnType where ClockType.Duration == Duration { 98 | let configuration = RetryConfiguration(maxAttempts: maxAttempts, 99 | clock: clock, 100 | backoff: backoff, 101 | appleLogger: appleLogger, 102 | logger: logger, 103 | recoverFromFailure: recoverFromFailure) 104 | 105 | return try await retry(with: configuration, 106 | operation: operation) 107 | } 108 | 109 | /// Attempts the given operation until it succeeds or until the failure is no longer retryable. 110 | /// Sleeps in between attempts using the given clock. 111 | /// 112 | /// Failures may not be retryable for the following reasons: 113 | /// - `recoverFromFailure` returns ``RecoveryAction/throw``. 114 | /// - The thrown error is ``NotRetryable``. 115 | /// - The number of attempts reached `maxAttempts`. 116 | /// 117 | /// - Parameters: 118 | /// - maxAttempts: The maximum number of times to attempt the operation. Must be greater than `0`. 119 | /// - clock: The clock that will be used to sleep in between attempts. 120 | /// - backoff: The choice of algorithm that will be used to determine how long to sleep in between attempts. 121 | /// - appleLogger: The logger that will be used to log a message when an attempt fails. The function will 122 | /// log messages using the `debug` log level. 123 | /// - logger: The logger that will be used to log a message when an attempt fails. The function will log 124 | /// messages using the `debug` log level. Consider using `appleLogger` when possible. 125 | /// - operation: The operation to attempt. 126 | /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. 127 | /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. 128 | /// 129 | /// - SeeAlso: ``retry(with:operation:)`` 130 | /// - SeeAlso: ``RetryableRequest`` 131 | public func retry( 132 | maxAttempts: Int? = 3, 133 | clock: ClockType, 134 | backoff: Backoff, 135 | appleLogger: os.Logger? = nil, 136 | logger: Logging.Logger? = nil, 137 | @_inheritActorContext @_implicitSelfCapture operation: () async throws -> ReturnType, 138 | recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } 139 | ) async throws -> ReturnType { 140 | let configuration = RetryConfiguration(maxAttempts: maxAttempts, 141 | clock: clock, 142 | backoff: backoff, 143 | appleLogger: appleLogger, 144 | logger: logger, 145 | recoverFromFailure: recoverFromFailure) 146 | 147 | return try await retry(with: configuration, 148 | operation: operation) 149 | } 150 | #else 151 | /// Attempts the given operation until it succeeds or until the failure is no longer retryable. 152 | /// Sleeps in between attempts using `ContinuousClock`. 153 | /// 154 | /// Failures may not be retryable for the following reasons: 155 | /// - `recoverFromFailure` returns ``RecoveryAction/throw``. 156 | /// - The thrown error is ``NotRetryable``. 157 | /// - The number of attempts reached `maxAttempts`. 158 | /// 159 | /// - Parameters: 160 | /// - maxAttempts: The maximum number of times to attempt the operation. Must be greater than `0`. 161 | /// - backoff: The choice of algorithm that will be used to determine how long to sleep in between attempts. 162 | /// - logger: The logger that will be used to log a message when an attempt fails. The function will log 163 | /// messages using the `debug` log level. 164 | /// - operation: The operation to attempt. 165 | /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. 166 | /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. 167 | /// 168 | /// - SeeAlso: ``retry(with:operation:)`` 169 | /// - SeeAlso: ``RetryableRequest`` 170 | public func retry( 171 | maxAttempts: Int? = 3, 172 | backoff: Backoff = .default(baseDelay: .seconds(1), maxDelay: .seconds(20)), 173 | logger: Logging.Logger? = nil, 174 | @_inheritActorContext @_implicitSelfCapture operation: () async throws -> ReturnType, 175 | recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } 176 | ) async throws -> ReturnType { 177 | return try await retry(maxAttempts: maxAttempts, 178 | clock: ContinuousClock(), 179 | backoff: backoff, 180 | logger: logger, 181 | operation: operation, 182 | recoverFromFailure: recoverFromFailure) 183 | } 184 | 185 | /// Attempts the given operation until it succeeds or until the failure is no longer retryable. 186 | /// Sleeps in between attempts using the given clock whose duration type is the standard `Duration` type. 187 | /// 188 | /// Failures may not be retryable for the following reasons: 189 | /// - `recoverFromFailure` returns ``RecoveryAction/throw``. 190 | /// - The thrown error is ``NotRetryable``. 191 | /// - The number of attempts reached `maxAttempts`. 192 | /// 193 | /// - Parameters: 194 | /// - maxAttempts: The maximum number of times to attempt the operation. Must be greater than `0`. 195 | /// - clock: The clock that will be used to sleep in between attempts. 196 | /// - backoff: The choice of algorithm that will be used to determine how long to sleep in between attempts. 197 | /// - logger: The logger that will be used to log a message when an attempt fails. The function will log 198 | /// messages using the `debug` log level. 199 | /// - operation: The operation to attempt. 200 | /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. 201 | /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. 202 | /// 203 | /// - SeeAlso: ``retry(with:operation:)`` 204 | /// - SeeAlso: ``RetryableRequest`` 205 | public func retry( 206 | maxAttempts: Int? = 3, 207 | clock: ClockType, 208 | backoff: Backoff = .default(baseDelay: .seconds(1), maxDelay: .seconds(20)), 209 | logger: Logging.Logger? = nil, 210 | @_inheritActorContext @_implicitSelfCapture operation: () async throws -> ReturnType, 211 | recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } 212 | ) async throws -> ReturnType where ClockType.Duration == Duration { 213 | let configuration = RetryConfiguration(maxAttempts: maxAttempts, 214 | clock: clock, 215 | backoff: backoff, 216 | logger: logger, 217 | recoverFromFailure: recoverFromFailure) 218 | 219 | return try await retry(with: configuration, 220 | operation: operation) 221 | } 222 | 223 | /// Attempts the given operation until it succeeds or until the failure is no longer retryable. 224 | /// Sleeps in between attempts using the given clock. 225 | /// 226 | /// Failures may not be retryable for the following reasons: 227 | /// - `recoverFromFailure` returns ``RecoveryAction/throw``. 228 | /// - The thrown error is ``NotRetryable``. 229 | /// - The number of attempts reached `maxAttempts`. 230 | /// 231 | /// - Parameters: 232 | /// - maxAttempts: The maximum number of times to attempt the operation. Must be greater than `0`. 233 | /// - clock: The clock that will be used to sleep in between attempts. 234 | /// - backoff: The choice of algorithm that will be used to determine how long to sleep in between attempts. 235 | /// - logger: The logger that will be used to log a message when an attempt fails. The function will log 236 | /// messages using the `debug` log level. 237 | /// - operation: The operation to attempt. 238 | /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. 239 | /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. 240 | /// 241 | /// - SeeAlso: ``retry(with:operation:)`` 242 | /// - SeeAlso: ``RetryableRequest`` 243 | public func retry( 244 | maxAttempts: Int? = 3, 245 | clock: ClockType, 246 | backoff: Backoff, 247 | logger: Logging.Logger? = nil, 248 | @_inheritActorContext @_implicitSelfCapture operation: () async throws -> ReturnType, 249 | recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } 250 | ) async throws -> ReturnType { 251 | let configuration = RetryConfiguration(maxAttempts: maxAttempts, 252 | clock: clock, 253 | backoff: backoff, 254 | logger: logger, 255 | recoverFromFailure: recoverFromFailure) 256 | 257 | return try await retry(with: configuration, 258 | operation: operation) 259 | } 260 | #endif 261 | 262 | /// Attempts the given operation until it succeeds or until the failure is no longer retryable. 263 | /// 264 | /// Failures may not be retryable for the following reasons: 265 | /// - ``RetryConfiguration/recoverFromFailure`` returns ``RecoveryAction/throw``. 266 | /// - The thrown error is ``NotRetryable``. 267 | /// - The number of attempts reached ``RetryConfiguration/maxAttempts``. 268 | /// 269 | /// - Parameters: 270 | /// - configuration: Configuration that specifies the behavior of this function. 271 | /// - operation: The operation to attempt. 272 | /// 273 | /// - Note: The function will log messages using the `debug` log level to ``RetryConfiguration/logger`` 274 | /// (and/or ``RetryConfiguration/appleLogger`` on Apple platforms). 275 | /// 276 | /// - SeeAlso: ``RetryableRequest`` 277 | public func retry( 278 | with configuration: RetryConfiguration, 279 | @_inheritActorContext @_implicitSelfCapture operation: () async throws -> ReturnType 280 | ) async throws -> ReturnType { 281 | let maxAttempts = configuration.maxAttempts 282 | 283 | let clock = configuration.clock 284 | var backoff = configuration.backoff.makeAlgorithm(clock: clock) 285 | 286 | var logger = configuration.logger 287 | #if canImport(OSLog) 288 | let appleLogger = configuration.appleLogger 289 | #endif 290 | 291 | let recoverFromFailure = configuration.recoverFromFailure 292 | 293 | if let maxAttempts { 294 | logger?[metadataKey: .maxAttempts] = "\(maxAttempts)" 295 | } 296 | #if canImport(OSLog) 297 | let maxAttemptsSentenceFragment: String 298 | if let maxAttempts { 299 | maxAttemptsSentenceFragment = " of \(maxAttempts)" 300 | } else { 301 | maxAttemptsSentenceFragment = "" 302 | } 303 | #endif 304 | 305 | var attempt = 1 306 | while true { 307 | var latestError: any Error 308 | var recoveryAction: RecoveryAction 309 | 310 | do { 311 | return try await operation() 312 | } catch { 313 | switch error { 314 | case is Retryable: 315 | recoveryAction = .retry 316 | 317 | case is NotRetryable: 318 | recoveryAction = .throw 319 | 320 | case is CancellationError: 321 | recoveryAction = .throw 322 | 323 | default: 324 | recoveryAction = recoverFromFailure(error) 325 | } 326 | 327 | latestError = error.originalError 328 | 329 | // Need to check again because the error could have been wrapped. 330 | if latestError is CancellationError { 331 | recoveryAction = .throw 332 | } 333 | } 334 | 335 | logger?[metadataKey: .attemptNumber] = "\(attempt)" 336 | logger?[metadataKey: .error] = "\(latestError)" 337 | logger?[metadataKey: .errorType] = "\(type(of: latestError))" 338 | 339 | switch recoveryAction { 340 | case .throw: 341 | logger?.debug("Attempt failed. Error is not retryable.") 342 | #if canImport(OSLog) 343 | appleLogger?.debug(""" 344 | Attempt \(attempt, privacy: .public) failed with error of type `\(type(of: latestError), privacy: .public)`: `\(latestError)`. \ 345 | Error is not retryable. 346 | """) 347 | #endif 348 | 349 | throw latestError 350 | 351 | case .retry: 352 | if let maxAttempts, attempt >= maxAttempts { 353 | logger?.debug("Attempt failed. No remaining attempts.") 354 | #if canImport(OSLog) 355 | appleLogger?.debug(""" 356 | Attempt \(attempt, privacy: .public) failed with error of type `\(type(of: latestError), privacy: .public)`: `\(latestError)`. \ 357 | No remaining attempts. 358 | """) 359 | #endif 360 | 361 | throw latestError 362 | } 363 | 364 | let delay = backoff.nextDelay() as! ClockType.Duration 365 | 366 | logger?.debug("Attempt failed. Will wait before retrying.", metadata: [ 367 | // Unfortunately, the generic `ClockType.Duration` does not have a way to convert `delay` 368 | // to a number, so we have to settle for the implementation-defined string representation. 369 | .retryDelay: "\(delay)" 370 | ]) 371 | #if canImport(OSLog) 372 | appleLogger?.debug(""" 373 | Attempt \(attempt, privacy: .public)\(maxAttemptsSentenceFragment, privacy: .public) failed with error of type `\(type(of: latestError), privacy: .public)`: `\(latestError)`. \ 374 | Will wait \(String(describing: delay), privacy: .public) before retrying. 375 | """) 376 | #endif 377 | 378 | try await clock.sleep(for: delay) 379 | 380 | attempt += 1 381 | 382 | case .retryAfter(let nextRetryMinInstant): 383 | let minDelay = clock.now.duration(to: nextRetryMinInstant) 384 | // Unfortunately, the generic `ClockType.Duration` does not have a way to convert `minDelay` 385 | // to a number, so we have to settle for the implementation-defined string representation. 386 | logger?[metadataKey: .requestedMinRetryDelay] = "\(minDelay)" 387 | 388 | var delay = ClockType.Duration.zero 389 | var attemptsUsedToAchieveMinDelay = 0 390 | repeat { 391 | if let maxAttempts { 392 | guard attempt + attemptsUsedToAchieveMinDelay < maxAttempts else { 393 | logger?.debug("Attempt failed. No remaining attempts after backing off normally to achieve the minimum delay.") 394 | #if canImport(OSLog) 395 | appleLogger?.debug(""" 396 | Attempt \(attempt, privacy: .public) failed with error of type `\(type(of: latestError), privacy: .public)`: `\(latestError)`. \ 397 | The `recoverFromFailure` closure requested a minimum delay of \(String(describing: minDelay)) before retrying. \ 398 | No remaining attempts after backing off normally to achieve the minimum delay. 399 | """) 400 | #endif 401 | 402 | throw latestError 403 | } 404 | } 405 | 406 | delay += backoff.nextDelay() as! ClockType.Duration 407 | 408 | attemptsUsedToAchieveMinDelay += 1 409 | } while delay < clock.now.duration(to: nextRetryMinInstant) 410 | 411 | logger?.debug("Attempt failed. Will wait before retrying.", metadata: [ 412 | // Unfortunately, the generic `ClockType.Duration` does not have a way to convert `delay` 413 | // to a number, so we have to settle for the implementation-defined string representation. 414 | .retryDelay: "\(delay)" 415 | ]) 416 | #if canImport(OSLog) 417 | appleLogger?.debug(""" 418 | Attempt \(attempt, privacy: .public)\(maxAttemptsSentenceFragment, privacy: .public) failed with error of type `\(type(of: latestError), privacy: .public)`: `\(latestError)`. \ 419 | The `recoverFromFailure` closure requested a minimum delay of \(String(describing: minDelay)) before retrying. \ 420 | Will wait \(String(describing: delay), privacy: .public) before retrying. 421 | """) 422 | #endif 423 | 424 | try await clock.sleep(for: delay) 425 | 426 | attempt += attemptsUsedToAchieveMinDelay 427 | } 428 | } 429 | } 430 | -------------------------------------------------------------------------------- /Sources/Retry/RetryConfiguration.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright © 2023 Darren Mo. 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Logging 24 | #if canImport(OSLog) 25 | // FB13460778: `Logger` does not currently conform to `Sendable` even though it is 26 | // likely already concurrency-safe. 27 | @preconcurrency import OSLog 28 | #endif 29 | 30 | /// Configures the retry behavior. 31 | public struct RetryConfiguration { 32 | /// The maximum number of times to attempt the operation. 33 | /// 34 | /// - Precondition: Must be greater than `0`. 35 | public var maxAttempts: Int? 36 | 37 | /// The clock that will be used to sleep in between attempts. 38 | public var clock: ClockType 39 | /// The algorithm that determines how long to wait in between attempts. 40 | public var backoff: Backoff 41 | 42 | #if canImport(OSLog) 43 | /// The logger that will be used to log a message when an attempt fails. 44 | public var appleLogger: os.Logger? 45 | #endif 46 | /// The logger that will be used to log a message when an attempt fails. 47 | /// 48 | /// - Remark: On Apple platforms, consider using ``appleLogger`` for potentially more 49 | /// detailed log messages and better integration with the logging system. 50 | public var logger: Logging.Logger? 51 | 52 | /// A closure that determines what action to take, given the error that was thrown. 53 | /// 54 | /// - Note: The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. 55 | public var recoverFromFailure: @Sendable (any Error) -> RecoveryAction 56 | 57 | #if canImport(OSLog) 58 | /// Configures the retry behavior when the clock’s duration type is the standard `Duration` type. 59 | /// 60 | /// - Parameters: 61 | /// - maxAttempts: The maximum number of times to attempt the operation. Must be greater than `0`. 62 | /// - clock: The clock that will be used to sleep in between attempts. 63 | /// - backoff: The choice of algorithm that will be used to determine how long to sleep in between attempts. 64 | /// - appleLogger: The logger that will be used to log a message when an attempt fails. The function will 65 | /// log messages using the `debug` log level. 66 | /// - logger: The logger that will be used to log a message when an attempt fails. The function will log 67 | /// messages using the `debug` log level. Consider using `appleLogger` when possible. 68 | /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. 69 | /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. 70 | public init( 71 | maxAttempts: Int? = 3, 72 | clock: ClockType = ContinuousClock(), 73 | backoff: Backoff = .default(baseDelay: .seconds(1), maxDelay: .seconds(20)), 74 | appleLogger: os.Logger? = nil, 75 | logger: Logging.Logger? = nil, 76 | recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } 77 | ) where ClockType.Duration == Duration { 78 | if let maxAttempts { 79 | precondition(maxAttempts > 0) 80 | } 81 | 82 | self.maxAttempts = maxAttempts 83 | 84 | self.clock = clock 85 | self.backoff = backoff 86 | 87 | self.appleLogger = appleLogger 88 | self.logger = logger 89 | 90 | self.recoverFromFailure = recoverFromFailure 91 | } 92 | 93 | /// Configures the retry behavior. 94 | /// 95 | /// - Parameters: 96 | /// - maxAttempts: The maximum number of times to attempt the operation. Must be greater than `0`. 97 | /// - clock: The clock that will be used to sleep in between attempts. 98 | /// - backoff: The choice of algorithm that will be used to determine how long to sleep in between attempts. 99 | /// - appleLogger: The logger that will be used to log a message when an attempt fails. The function will 100 | /// log messages using the `debug` log level. 101 | /// - logger: The logger that will be used to log a message when an attempt fails. The function will log 102 | /// messages using the `debug` log level. Consider using `appleLogger` when possible. 103 | /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. 104 | /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. 105 | public init( 106 | maxAttempts: Int? = 3, 107 | clock: ClockType, 108 | backoff: Backoff, 109 | appleLogger: os.Logger? = nil, 110 | logger: Logging.Logger? = nil, 111 | recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } 112 | ) { 113 | if let maxAttempts { 114 | precondition(maxAttempts > 0) 115 | } 116 | 117 | self.maxAttempts = maxAttempts 118 | 119 | self.clock = clock 120 | self.backoff = backoff 121 | 122 | self.appleLogger = appleLogger 123 | self.logger = logger 124 | 125 | self.recoverFromFailure = recoverFromFailure 126 | } 127 | #else 128 | /// Configures the retry behavior when the clock’s duration type is the standard `Duration` type. 129 | /// 130 | /// - Parameters: 131 | /// - maxAttempts: The maximum number of times to attempt the operation. Must be greater than `0`. 132 | /// - clock: The clock that will be used to sleep in between attempts. 133 | /// - backoff: The choice of algorithm that will be used to determine how long to sleep in between attempts. 134 | /// - logger: The logger that will be used to log a message when an attempt fails. The function will log 135 | /// messages using the `debug` log level. 136 | /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. 137 | /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. 138 | public init( 139 | maxAttempts: Int? = 3, 140 | clock: ClockType = ContinuousClock(), 141 | backoff: Backoff = .default(baseDelay: .seconds(1), maxDelay: .seconds(20)), 142 | logger: Logging.Logger? = nil, 143 | recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } 144 | ) where ClockType.Duration == Duration { 145 | if let maxAttempts { 146 | precondition(maxAttempts > 0) 147 | } 148 | 149 | self.maxAttempts = maxAttempts 150 | 151 | self.clock = clock 152 | self.backoff = backoff 153 | 154 | self.logger = logger 155 | 156 | self.recoverFromFailure = recoverFromFailure 157 | } 158 | 159 | /// Configures the retry behavior. 160 | /// 161 | /// - Parameters: 162 | /// - maxAttempts: The maximum number of times to attempt the operation. Must be greater than `0`. 163 | /// - clock: The clock that will be used to sleep in between attempts. 164 | /// - backoff: The choice of algorithm that will be used to determine how long to sleep in between attempts. 165 | /// - logger: The logger that will be used to log a message when an attempt fails. The function will log 166 | /// messages using the `debug` log level. 167 | /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. 168 | /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. 169 | public init( 170 | maxAttempts: Int? = 3, 171 | clock: ClockType, 172 | backoff: Backoff, 173 | logger: Logging.Logger? = nil, 174 | recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } 175 | ) { 176 | if let maxAttempts { 177 | precondition(maxAttempts > 0) 178 | } 179 | 180 | self.maxAttempts = maxAttempts 181 | 182 | self.clock = clock 183 | self.backoff = backoff 184 | 185 | self.logger = logger 186 | 187 | self.recoverFromFailure = recoverFromFailure 188 | } 189 | #endif 190 | 191 | public func withMaxAttempts(_ newValue: Int?) -> Self { 192 | var newConfiguration = self 193 | newConfiguration.maxAttempts = newValue 194 | return newConfiguration 195 | } 196 | 197 | public func withClock(_ newValue: ClockType) -> Self { 198 | var newConfiguration = self 199 | newConfiguration.clock = newValue 200 | return newConfiguration 201 | } 202 | 203 | public func withBackoff(_ newValue: Backoff) -> Self { 204 | var newConfiguration = self 205 | newConfiguration.backoff = newValue 206 | return newConfiguration 207 | } 208 | 209 | #if canImport(OSLog) 210 | public func withAppleLogger(_ newValue: os.Logger?) -> Self { 211 | var newConfiguration = self 212 | newConfiguration.appleLogger = newValue 213 | return newConfiguration 214 | } 215 | #endif 216 | 217 | public func withLogger(_ newValue: Logging.Logger?) -> Self { 218 | var newConfiguration = self 219 | newConfiguration.logger = newValue 220 | return newConfiguration 221 | } 222 | 223 | public func withRecoverFromFailure(_ newValue: @escaping @Sendable (any Error) -> RecoveryAction) -> Self { 224 | var newConfiguration = self 225 | newConfiguration.recoverFromFailure = newValue 226 | return newConfiguration 227 | } 228 | } 229 | 230 | extension RetryConfiguration: Sendable { 231 | } 232 | -------------------------------------------------------------------------------- /Sources/Retry/Retryable/Error+OriginalError.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright © 2023 Darren Mo. 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | extension Error { 24 | var originalError: any Error { 25 | switch self { 26 | case let error as Retryable: 27 | return error.underlyingError.originalError 28 | 29 | case let error as NotRetryable: 30 | return error.underlyingError.originalError 31 | 32 | default: 33 | return self 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/Retry/Retryable/NotRetryable.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright © 2023 Darren Mo. 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | /// A concrete error type that is never retryable and wraps an underlying error. 24 | /// 25 | /// Throwing this error will prevent a retry. 26 | /// 27 | /// This wrapper type exists for the cases where ``RetryConfiguration/recoverFromFailure`` 28 | /// cannot make a good decision (e.g. the underlying error type is not exposed by a library dependency). 29 | public struct NotRetryable: Error { 30 | let underlyingError: any Error 31 | 32 | /// Wraps the given error. 33 | /// 34 | /// - Parameter underlyingError: The error being wrapped. This will be the actual error thrown. 35 | public init(_ underlyingError: any Error) { 36 | self.underlyingError = underlyingError 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/Retry/Retryable/Retryable.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright © 2023 Darren Mo. 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | /// A concrete error type that is always retryable and wraps an underlying error. 24 | /// 25 | /// Throwing this error will always result in a retry, unless there are other conditions that make the failure 26 | /// not retryable like reaching the maximum number of attempts. 27 | /// 28 | /// This wrapper type exists for the cases where ``RetryConfiguration/recoverFromFailure`` 29 | /// cannot make a good decision (e.g. the underlying error type is not exposed by a library dependency). 30 | public struct Retryable: Error { 31 | let underlyingError: any Error 32 | 33 | /// Wraps the given error. 34 | /// 35 | /// - Parameter underlyingError: The error being wrapped. This will be the actual error thrown 36 | /// if the failure is no longer retryable. 37 | public init(_ underlyingError: any Error) { 38 | self.underlyingError = underlyingError 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/Retry/RetryableRequest/RetryableRequest+SafeRetry.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright © 2023 Darren Mo. 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Logging 24 | #if canImport(OSLog) 25 | import OSLog 26 | #endif 27 | 28 | extension RetryableRequest { 29 | #if canImport(OSLog) 30 | /// Attempts the given operation until it succeeds or until the failure is no longer retryable. 31 | /// Sleeps in between attempts using the given clock whose duration type is the standard `Duration` type. 32 | /// 33 | /// Failures may not be retryable for the following reasons: 34 | /// - The response indicates that the failure is not transient. 35 | /// - `recoverFromFailure` returns ``RecoveryAction/throw``. 36 | /// - The thrown error is ``NotRetryable``. 37 | /// - The number of attempts reached `maxAttempts`. 38 | /// 39 | /// - Precondition: ``isIdempotent`` must return `true`. 40 | /// 41 | /// - Parameters: 42 | /// - maxAttempts: The maximum number of times to attempt the operation. Must be greater than `0`. 43 | /// - clock: The clock that will be used to sleep in between attempts. 44 | /// - backoff: The choice of algorithm that will be used to determine how long to sleep in between attempts. 45 | /// - appleLogger: The logger that will be used to log a message when an attempt fails. The function will 46 | /// log messages using the `debug` log level. 47 | /// - logger: The logger that will be used to log a message when an attempt fails. The function will log 48 | /// messages using the `debug` log level. Consider using `appleLogger` when possible. 49 | /// - operation: Attempts the given request. 50 | /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. 51 | /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. 52 | /// 53 | /// - SeeAlso: ``retry(with:operation:)`` 54 | public func retry( 55 | maxAttempts: Int? = 3, 56 | clock: ClockType = ContinuousClock(), 57 | backoff: Backoff = .default(baseDelay: .seconds(1), maxDelay: .seconds(20)), 58 | appleLogger: os.Logger? = nil, 59 | logger: Logging.Logger? = nil, 60 | @_inheritActorContext @_implicitSelfCapture operation: (Self) async throws -> ReturnType, 61 | recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } 62 | ) async throws -> ReturnType where ClockType.Duration == Duration { 63 | let configuration = RetryConfiguration(maxAttempts: maxAttempts, 64 | clock: clock, 65 | backoff: backoff, 66 | appleLogger: appleLogger, 67 | logger: logger, 68 | recoverFromFailure: recoverFromFailure) 69 | 70 | return try await retry(with: configuration, 71 | operation: operation) 72 | } 73 | 74 | /// Attempts the given operation until it succeeds or until the failure is no longer retryable. 75 | /// Sleeps in between attempts using the given clock. 76 | /// 77 | /// Failures may not be retryable for the following reasons: 78 | /// - The response indicates that the failure is not transient. 79 | /// - `recoverFromFailure` returns ``RecoveryAction/throw``. 80 | /// - The thrown error is ``NotRetryable``. 81 | /// - The number of attempts reached `maxAttempts`. 82 | /// 83 | /// - Precondition: ``isIdempotent`` must return `true`. 84 | /// 85 | /// - Parameters: 86 | /// - maxAttempts: The maximum number of times to attempt the operation. Must be greater than `0`. 87 | /// - clock: The clock that will be used to sleep in between attempts. 88 | /// - backoff: The choice of algorithm that will be used to determine how long to sleep in between attempts. 89 | /// - appleLogger: The logger that will be used to log a message when an attempt fails. The function will 90 | /// log messages using the `debug` log level. 91 | /// - logger: The logger that will be used to log a message when an attempt fails. The function will log 92 | /// messages using the `debug` log level. Consider using `appleLogger` when possible. 93 | /// - operation: Attempts the given request. 94 | /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. 95 | /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. 96 | /// 97 | /// - SeeAlso: ``retry(with:operation:)`` 98 | public func retry( 99 | maxAttempts: Int? = 3, 100 | clock: ClockType, 101 | backoff: Backoff, 102 | appleLogger: os.Logger? = nil, 103 | logger: Logging.Logger? = nil, 104 | @_inheritActorContext @_implicitSelfCapture operation: (Self) async throws -> ReturnType, 105 | recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } 106 | ) async throws -> ReturnType { 107 | let configuration = RetryConfiguration(maxAttempts: maxAttempts, 108 | clock: clock, 109 | backoff: backoff, 110 | appleLogger: appleLogger, 111 | logger: logger, 112 | recoverFromFailure: recoverFromFailure) 113 | 114 | return try await retry(with: configuration, 115 | operation: operation) 116 | } 117 | #else 118 | /// Attempts the given operation until it succeeds or until the failure is no longer retryable. 119 | /// Sleeps in between attempts using the given clock whose duration type is the standard `Duration` type. 120 | /// 121 | /// Failures may not be retryable for the following reasons: 122 | /// - The response indicates that the failure is not transient. 123 | /// - `recoverFromFailure` returns ``RecoveryAction/throw``. 124 | /// - The thrown error is ``NotRetryable``. 125 | /// - The number of attempts reached `maxAttempts`. 126 | /// 127 | /// - Precondition: ``isIdempotent`` must return `true`. 128 | /// 129 | /// - Parameters: 130 | /// - maxAttempts: The maximum number of times to attempt the operation. Must be greater than `0`. 131 | /// - clock: The clock that will be used to sleep in between attempts. 132 | /// - backoff: The choice of algorithm that will be used to determine how long to sleep in between attempts. 133 | /// - logger: The logger that will be used to log a message when an attempt fails. The function will log 134 | /// messages using the `debug` log level. 135 | /// - operation: Attempts the given request. 136 | /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. 137 | /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. 138 | /// 139 | /// - SeeAlso: ``retry(with:operation:)`` 140 | public func retry( 141 | maxAttempts: Int? = 3, 142 | clock: ClockType = ContinuousClock(), 143 | backoff: Backoff = .default(baseDelay: .seconds(1), maxDelay: .seconds(20)), 144 | logger: Logging.Logger? = nil, 145 | @_inheritActorContext @_implicitSelfCapture operation: (Self) async throws -> ReturnType, 146 | recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } 147 | ) async throws -> ReturnType where ClockType.Duration == Duration { 148 | let configuration = RetryConfiguration(maxAttempts: maxAttempts, 149 | clock: clock, 150 | backoff: backoff, 151 | logger: logger, 152 | recoverFromFailure: recoverFromFailure) 153 | 154 | return try await retry(with: configuration, 155 | operation: operation) 156 | } 157 | 158 | /// Attempts the given operation until it succeeds or until the failure is no longer retryable. 159 | /// Sleeps in between attempts using the given clock. 160 | /// 161 | /// Failures may not be retryable for the following reasons: 162 | /// - The response indicates that the failure is not transient. 163 | /// - `recoverFromFailure` returns ``RecoveryAction/throw``. 164 | /// - The thrown error is ``NotRetryable``. 165 | /// - The number of attempts reached `maxAttempts`. 166 | /// 167 | /// - Precondition: ``isIdempotent`` must return `true`. 168 | /// 169 | /// - Parameters: 170 | /// - maxAttempts: The maximum number of times to attempt the operation. Must be greater than `0`. 171 | /// - clock: The clock that will be used to sleep in between attempts. 172 | /// - backoff: The choice of algorithm that will be used to determine how long to sleep in between attempts. 173 | /// - logger: The logger that will be used to log a message when an attempt fails. The function will log 174 | /// messages using the `debug` log level. 175 | /// - operation: Attempts the given request. 176 | /// - recoverFromFailure: A closure that determines what action to take, given the error that was thrown. 177 | /// The closure will not be called if the error is ``Retryable`` or ``NotRetryable``. 178 | /// 179 | /// - SeeAlso: ``retry(with:operation:)`` 180 | public func retry( 181 | maxAttempts: Int? = 3, 182 | clock: ClockType, 183 | backoff: Backoff, 184 | logger: Logging.Logger? = nil, 185 | @_inheritActorContext @_implicitSelfCapture operation: (Self) async throws -> ReturnType, 186 | recoverFromFailure: @escaping @Sendable (any Error) -> RecoveryAction = { _ in .retry } 187 | ) async throws -> ReturnType { 188 | let configuration = RetryConfiguration(maxAttempts: maxAttempts, 189 | clock: clock, 190 | backoff: backoff, 191 | logger: logger, 192 | recoverFromFailure: recoverFromFailure) 193 | 194 | return try await retry(with: configuration, 195 | operation: operation) 196 | } 197 | #endif 198 | 199 | /// Attempts the given operation until it succeeds or until the failure is no longer retryable. 200 | /// 201 | /// Failures may not be retryable for the following reasons: 202 | /// - The response indicates that the failure is not transient. 203 | /// - ``RetryConfiguration/recoverFromFailure`` returns ``RecoveryAction/throw``. 204 | /// - The thrown error is ``NotRetryable``. 205 | /// - The number of attempts reached ``RetryConfiguration/maxAttempts``. 206 | /// 207 | /// - Precondition: ``isIdempotent`` must return `true`. 208 | /// 209 | /// - Parameters: 210 | /// - configuration: Configuration that specifies the behavior of this function. 211 | /// - operation: Attempts the given request. 212 | /// 213 | /// - Note: The function will log messages using the `debug` log level to ``RetryConfiguration/logger`` 214 | /// (and/or ``RetryConfiguration/appleLogger`` on Apple platforms). 215 | public func retry( 216 | with configuration: RetryConfiguration, 217 | @_inheritActorContext @_implicitSelfCapture operation: (Self) async throws -> ReturnType 218 | ) async throws -> ReturnType { 219 | precondition(isIdempotent, "The request is not idempotent: `\(self)`.") 220 | 221 | return try await unsafeRetryIgnoringIdempotency(with: configuration, 222 | operation: operation) 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /Sources/Retry/RetryableRequest/RetryableRequest.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright © 2023 Darren Mo. 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | /// A protocol that adds safe retry methods to a conforming request type. 24 | /// 25 | /// The retry methods in this protocol are similar to the top-level retry functions, but safer. The retry 26 | /// methods in this protocol enforce that ``isIdempotent`` returns `true` since it is unsafe to 27 | /// retry a non-idempotent request. 28 | /// 29 | /// Conform request types to this protocol when ``isIdempotent`` can be implemented accurately. 30 | /// For example, the HTTP specification defines certain HTTP request methods as idempotent, so it 31 | /// would be straightforward to conform an HTTP request type to this protocol. 32 | /// 33 | /// Conforming request types also need to implement 34 | /// ``unsafeRetryIgnoringIdempotency(with:operation:)``. Implementations may choose 35 | /// to override ``RetryConfiguration/recoverFromFailure`` to automatically handle errors 36 | /// specific to the communication protocol. 37 | public protocol RetryableRequest { 38 | /// Determines whether the request is idempotent. 39 | /// 40 | /// A request is considered idempotent if the intended effect on the server of multiple 41 | /// identical requests is the same as the effect for a single such request. 42 | var isIdempotent: Bool { get } 43 | 44 | /// Attempts the given operation until it succeeds or until the failure is no longer retryable. 45 | /// 46 | /// - Warning: This method is unsafe because it does not check ``isIdempotent``. 47 | /// Consider using ``retry(with:operation:)`` instead. 48 | /// 49 | /// Failures may not be retryable for the following reasons: 50 | /// - The response indicates that the failure is not transient. 51 | /// - ``RetryConfiguration/recoverFromFailure`` returns ``RecoveryAction/throw``. 52 | /// - The thrown error is ``NotRetryable``. 53 | /// - The number of attempts reached ``RetryConfiguration/maxAttempts``. 54 | /// 55 | /// - Parameters: 56 | /// - configuration: Configuration that specifies the behavior of this function. 57 | /// - operation: Attempts the given request. 58 | /// 59 | /// - Note: The function will log messages using the `debug` log level to ``RetryConfiguration/logger`` 60 | /// (and/or ``RetryConfiguration/appleLogger`` on Apple platforms). 61 | func unsafeRetryIgnoringIdempotency( 62 | with configuration: RetryConfiguration, 63 | @_inheritActorContext @_implicitSelfCapture operation: (Self) async throws -> ReturnType 64 | ) async throws -> ReturnType 65 | } 66 | -------------------------------------------------------------------------------- /Tests/RetryTests/ConstantBackoffTests.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright © 2023 Darren Mo. 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | @testable import Retry 24 | 25 | import XCTest 26 | 27 | final class ConstantBackoffTests: XCTestCase { 28 | func testIsConstant() { 29 | for constantDelayInSeconds in 0..<3 { 30 | let constantDelay = Duration.seconds(constantDelayInSeconds) 31 | 32 | let backoff = ConstantBackoff(delay: constantDelay) 33 | 34 | for _ in 0..<100 { 35 | let delay = backoff.nextDelay() 36 | 37 | XCTAssertEqual(delay, constantDelay) 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/RetryTests/Fakes/BackoffAlgorithmFake.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright © 2023 Darren Mo. 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Retry 24 | 25 | struct BackoffAlgorithmFake: BackoffAlgorithm { 26 | private let clock: ClockType 27 | 28 | private var attempt = 0 29 | 30 | init(clock: ClockType) { 31 | self.clock = clock 32 | } 33 | 34 | mutating func nextDelay() -> ClockType.Duration { 35 | defer { 36 | attempt += 1 37 | } 38 | 39 | return clock.minimumResolution * attempt 40 | } 41 | 42 | static func delays(ofCount delayCount: Int, 43 | for clock: ClockType) -> [ClockType.Duration] { 44 | var algorithm = BackoffAlgorithmFake(clock: clock) 45 | return (0..( 37 | in range: ClosedRange 38 | ) -> T where T : BinaryFloatingPoint, T.RawSignificand : FixedWidthInteger { 39 | switch mode { 40 | case .min: 41 | return range.lowerBound 42 | 43 | case .max: 44 | return range.upperBound 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Tests/RetryTests/FullJitterExponentialBackoffTests.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright © 2023 Darren Mo. 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | @testable import Retry 24 | 25 | import XCTest 26 | 27 | final class FullJitterExponentialBackoffTests: XCTestCase { 28 | private let clock = ContinuousClock() 29 | 30 | // MARK: - Tests 31 | 32 | func testIsExponentialWithFullJitter() { 33 | let baseDelay = Duration.seconds(3) 34 | 35 | let randomNumberGeneratorFake = RandomNumberGeneratorFake(mode: .max) 36 | var algorithm = FullJitterExponentialBackoff( 37 | clock: clock, 38 | baseDelay: baseDelay, 39 | maxDelay: nil, 40 | randomNumberGenerator: randomNumberGeneratorFake 41 | ) 42 | 43 | let delay1 = algorithm.nextDelay() 44 | assertEqualDelays(delay1, baseDelay) 45 | 46 | let delay2 = algorithm.nextDelay() 47 | assertEqualDelays(delay2, baseDelay * 2) 48 | 49 | let delay3 = algorithm.nextDelay() 50 | assertEqualDelays(delay3, baseDelay * 4) 51 | 52 | randomNumberGeneratorFake.mode = .min 53 | let delay4 = algorithm.nextDelay() 54 | assertEqualDelays(delay4, .zero) 55 | 56 | randomNumberGeneratorFake.mode = .max 57 | let delay5 = algorithm.nextDelay() 58 | assertEqualDelays(delay5, baseDelay * 16) 59 | } 60 | 61 | func testMaxDelay_normalValue() { 62 | let baseDelay = Duration.seconds(1) 63 | let maxDelay = Duration.seconds(3) 64 | 65 | var algorithm = FullJitterExponentialBackoff( 66 | clock: clock, 67 | baseDelay: baseDelay, 68 | maxDelay: maxDelay, 69 | randomNumberGenerator: RandomNumberGeneratorFake(mode: .max) 70 | ) 71 | 72 | let delay1 = algorithm.nextDelay() 73 | assertEqualDelays(delay1, baseDelay) 74 | 75 | let delay2 = algorithm.nextDelay() 76 | assertEqualDelays(delay2, baseDelay * 2) 77 | 78 | let delay3 = algorithm.nextDelay() 79 | assertEqualDelays(delay3, maxDelay) 80 | 81 | let delay4 = algorithm.nextDelay() 82 | assertEqualDelays(delay4, maxDelay) 83 | } 84 | 85 | func testMaxDelay_extremeValue() { 86 | let maxDelay = Duration(secondsComponent: .max, 87 | attosecondsComponent: 0) 88 | 89 | var algorithm = FullJitterExponentialBackoff( 90 | clock: clock, 91 | baseDelay: .seconds(1), 92 | maxDelay: maxDelay, 93 | randomNumberGenerator: RandomNumberGeneratorFake(mode: .max) 94 | ) 95 | 96 | let (maxDelaySecondsComponent, maxDelayAttosecondsComponent) = maxDelay.components 97 | // Make sure the delay has increased to the max. 98 | for _ in 0..<(maxDelaySecondsComponent.bitWidth + maxDelayAttosecondsComponent.bitWidth) { 99 | _ = algorithm.nextDelay() 100 | } 101 | 102 | let delay1 = algorithm.nextDelay() 103 | XCTAssertLessThanOrEqual(delay1, maxDelay) 104 | 105 | let delay2 = algorithm.nextDelay() 106 | XCTAssertEqual(delay2, delay1) 107 | } 108 | 109 | func testMaxDelay_notSpecified_hasImplicitMaxDelay() { 110 | var algorithm = FullJitterExponentialBackoff( 111 | clock: clock, 112 | baseDelay: .seconds(1), 113 | maxDelay: nil, 114 | randomNumberGenerator: RandomNumberGeneratorFake(mode: .max) 115 | ) 116 | 117 | let implicitMaxDelayInClockTicks = type(of: algorithm).implicitMaxDelayInClockTicks 118 | let implicitMaxDelay = clock.minimumResolution * implicitMaxDelayInClockTicks 119 | 120 | // Make sure the delay has increased to the max. 121 | for _ in 0..! 32 | 33 | override func setUp() { 34 | super.setUp() 35 | 36 | clockFake = ClockFake() 37 | testingConfiguration = RetryConfiguration( 38 | maxAttempts: Self.maxAttempts, 39 | clock: clockFake, 40 | backoff: Backoff { BackoffAlgorithmFake(clock: $0) }, 41 | recoverFromFailure: { _ in .retry } 42 | ) 43 | } 44 | 45 | override func tearDown() { 46 | clockFake = nil 47 | testingConfiguration = nil 48 | 49 | super.tearDown() 50 | } 51 | 52 | // MARK: - Tests 53 | 54 | func testNoFailure_successWithoutRetry() async throws { 55 | try await retry(with: testingConfiguration) { 56 | // Success. 57 | } 58 | 59 | assertRetried(times: 0) 60 | } 61 | 62 | func testOneFailure_successAfterRetry() async throws { 63 | precondition(Self.maxAttempts > 1) 64 | 65 | var isFirstAttempt = true 66 | 67 | try await retry(with: testingConfiguration) { 68 | if isFirstAttempt { 69 | isFirstAttempt = false 70 | 71 | throw ErrorFake() 72 | } else { 73 | // Success. 74 | } 75 | } 76 | 77 | assertRetried(times: 1) 78 | } 79 | 80 | func testAllAttemptsFail_failureAfterRetries() async throws { 81 | try await assertThrows(ErrorFake.self) { 82 | try await retry(with: testingConfiguration) { 83 | throw ErrorFake() 84 | } 85 | } 86 | 87 | assertRetried(times: Self.maxAttempts - 1) 88 | } 89 | 90 | func testFailure_recoverFromFailureDecidesToThrow_failureWithoutRetry() async throws { 91 | precondition(Self.maxAttempts > 1) 92 | 93 | try await assertThrows(ErrorFake.self) { 94 | try await retry(with: testingConfiguration.withRecoverFromFailure({ _ in .throw })) { 95 | throw ErrorFake() 96 | } 97 | } 98 | 99 | assertRetried(times: 0) 100 | } 101 | 102 | func testOneFailure_recoverFromFailureRequestsMinDelay_didNotReachMaxAttempts_successAfterRetry() async throws { 103 | precondition(Self.maxAttempts > 1) 104 | 105 | let clockFake = clockFake! 106 | let configuration = testingConfiguration.withRecoverFromFailure { _ in 107 | let minDelay = ( 108 | BackoffAlgorithmFake.delays(ofCount: Self.maxAttempts - 1, for: clockFake) 109 | .reduce(.zero, +) 110 | ) 111 | 112 | return .retryAfter(clockFake.now + minDelay) 113 | } 114 | 115 | var isFirstAttempt = true 116 | 117 | try await retry(with: configuration) { 118 | if isFirstAttempt { 119 | isFirstAttempt = false 120 | 121 | throw ErrorFake() 122 | } else { 123 | // Success. 124 | } 125 | } 126 | 127 | assertRetried(times: 1) 128 | } 129 | 130 | func testFailure_recoverFromFailureRequestsMinDelay_reachedMaxAttempts_failureWithoutRetry() async throws { 131 | precondition(Self.maxAttempts > 1) 132 | 133 | let clockFake = clockFake! 134 | let configuration = testingConfiguration.withRecoverFromFailure { _ in 135 | let minDelay = ( 136 | BackoffAlgorithmFake.delays(ofCount: Self.maxAttempts - 1, for: clockFake) 137 | .reduce(.zero, +) 138 | + clockFake.minimumResolution 139 | ) 140 | 141 | return .retryAfter(clockFake.now + minDelay) 142 | } 143 | 144 | var isFirstAttempt = true 145 | 146 | try await assertThrows(ErrorFake.self) { 147 | try await retry(with: configuration) { 148 | if isFirstAttempt { 149 | isFirstAttempt = false 150 | 151 | throw ErrorFake() 152 | } else { 153 | // Success. 154 | } 155 | } 156 | } 157 | 158 | assertRetried(times: 0) 159 | } 160 | 161 | func testFailure_isNotRetryableError_recoverFromFailureNotCalled_failureWithoutRetry() async throws { 162 | precondition(Self.maxAttempts > 1) 163 | 164 | let configuration = testingConfiguration.withRecoverFromFailure { error in 165 | XCTFail("`recoverFromFailure` should not be called when the error is `NotRetryable`.") 166 | return .retry 167 | } 168 | 169 | try await assertThrows(ErrorFake.self) { 170 | try await retry(with: configuration) { 171 | throw NotRetryable(ErrorFake()) 172 | } 173 | } 174 | 175 | assertRetried(times: 0) 176 | } 177 | 178 | func testOneFailure_isRetryableError_recoverFromFailureNotCalled_successAfterRetry() async throws { 179 | precondition(Self.maxAttempts > 1) 180 | 181 | let configuration = testingConfiguration.withRecoverFromFailure { error in 182 | XCTFail("`recoverFromFailure` should not be called when the error is `Retryable`.") 183 | return .throw 184 | } 185 | 186 | var isFirstAttempt = true 187 | 188 | try await retry(with: configuration) { 189 | if isFirstAttempt { 190 | isFirstAttempt = false 191 | 192 | throw Retryable(ErrorFake()) 193 | } else { 194 | // Success. 195 | } 196 | } 197 | 198 | assertRetried(times: 1) 199 | } 200 | 201 | func testAllAttemptsFail_latestErrorIsRetryableError_throwsOriginalError() async throws { 202 | try await assertThrows(ErrorFake.self) { 203 | try await retry(with: testingConfiguration) { 204 | throw Retryable(NotRetryable(ErrorFake())) 205 | } 206 | } 207 | 208 | assertRetried(times: Self.maxAttempts - 1) 209 | } 210 | 211 | func testFailure_isNotRetryableError_throwsOriginalError() async throws { 212 | try await assertThrows(ErrorFake.self) { 213 | try await retry(with: testingConfiguration) { 214 | throw NotRetryable(Retryable(ErrorFake())) 215 | } 216 | } 217 | 218 | assertRetried(times: 0) 219 | } 220 | 221 | func testFailure_isCancellationError_recoverFromFailureNotCalled_failureWithoutRetry() async throws { 222 | precondition(Self.maxAttempts > 1) 223 | 224 | let configuration = testingConfiguration.withRecoverFromFailure { error in 225 | XCTFail("`recoverFromFailure` should not be called when the error is `CancellationError`.") 226 | return .retry 227 | } 228 | 229 | try await assertThrows(CancellationError.self) { 230 | try await retry(with: configuration) { 231 | throw CancellationError() 232 | } 233 | } 234 | 235 | assertRetried(times: 0) 236 | } 237 | 238 | func testFailure_isCancellationErrorWrappedInRetryableError_failureWithoutRetry() async throws { 239 | precondition(Self.maxAttempts > 1) 240 | 241 | try await assertThrows(CancellationError.self) { 242 | try await retry(with: testingConfiguration) { 243 | throw Retryable(CancellationError()) 244 | } 245 | } 246 | 247 | assertRetried(times: 0) 248 | } 249 | 250 | func testFailure_isCancellationErrorWrappedInNotRetryableError_failureWithoutRetry() async throws { 251 | precondition(Self.maxAttempts > 1) 252 | 253 | try await assertThrows(CancellationError.self) { 254 | try await retry(with: testingConfiguration) { 255 | throw NotRetryable(CancellationError()) 256 | } 257 | } 258 | 259 | assertRetried(times: 0) 260 | } 261 | 262 | func testCancelledDuringSleep_immediateFailure() async throws { 263 | precondition(Self.maxAttempts > 1) 264 | 265 | clockFake.isSleepEnabled = true 266 | let configuration = testingConfiguration.withBackoff(.constant(.seconds(60))) 267 | 268 | let retryTask = Task { 269 | try await retry(with: configuration) { 270 | throw ErrorFake() 271 | } 272 | } 273 | 274 | // Wait until the retry task is sleeping after the first attempt. 275 | while clockFake.allSleepDurations.isEmpty { 276 | try await Task.sleep(for: .milliseconds(1)) 277 | } 278 | 279 | retryTask.cancel() 280 | 281 | let realClock = ContinuousClock() 282 | let start = realClock.now 283 | 284 | try await assertThrows(CancellationError.self) { 285 | try await retryTask.value 286 | } 287 | 288 | let end = realClock.now 289 | let duration = end - start 290 | XCTAssertLessThan(duration, .seconds(1)) 291 | } 292 | 293 | // MARK: - Assertions 294 | 295 | private func assertThrows( 296 | _ errorType: T.Type, 297 | operation: () async throws -> Void 298 | ) async throws { 299 | do { 300 | try await operation() 301 | } catch is T { 302 | // Expected. 303 | } 304 | } 305 | 306 | private func assertRetried(times retryCount: Int) { 307 | XCTAssertEqual(clockFake.allSleepDurations, 308 | BackoffAlgorithmFake.delays(ofCount: retryCount, for: clockFake)) 309 | } 310 | } 311 | --------------------------------------------------------------------------------