├── Sources ├── _PowShims │ ├── _PowShims.c │ └── include │ │ └── _PowShims.h └── Retry │ └── Retry.swift ├── .spi.yml ├── .gitignore ├── .editorconfig ├── Package.swift ├── LICENSE ├── Tests └── RetryTests │ ├── Xoshiro.swift │ ├── Clocks │ ├── AnyClock.swift │ ├── ImmediateClock.swift │ ├── UnimplementedClock.swift │ └── TestClock.swift │ └── RetryTests.swift └── README.md /Sources/_PowShims/_PowShims.c: -------------------------------------------------------------------------------- 1 | #include "_PowShims.h" 2 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [Retry] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /.swiftpm 4 | /Packages 5 | /*.xcodeproj 6 | xcuserdata/ 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /Sources/_PowShims/include/_PowShims.h: -------------------------------------------------------------------------------- 1 | // Trick from swift-numerics to get pow without Foundation 2 | static inline __attribute__((__always_inline__)) double pow(double x, double y) { 3 | return __builtin_pow(x, y); 4 | } 5 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "swift-concurrency-retry", 6 | platforms: [.iOS(.v16), .macOS(.v13), .macCatalyst(.v16), .tvOS(.v16), .watchOS(.v9), .visionOS(.v1)], 7 | products: [ 8 | .library(name: "Retry", targets: ["Retry"]), 9 | ], 10 | targets: [ 11 | .target( 12 | name: "_PowShims" 13 | ), 14 | .target( 15 | name: "Retry", 16 | dependencies: ["_PowShims"] 17 | ), 18 | .testTarget( 19 | name: "RetryTests", 20 | dependencies: ["Retry"] 21 | ) 22 | ] 23 | ) 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Philipp Gabriel 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 | -------------------------------------------------------------------------------- /Tests/RetryTests/Xoshiro.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2019 Point-Free, Inc. 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 | // An implementation of xoshiro256**: http://xoshiro.di.unimi.it. 23 | public struct Xoshiro: RandomNumberGenerator { 24 | @usableFromInline 25 | var state: (UInt64, UInt64, UInt64, UInt64) 26 | 27 | @inlinable 28 | public init(seed: UInt64) { 29 | self.state = (seed, 18_446_744, 073_709, 551_615) 30 | for _ in 1...10 { _ = self.next() } // perturb 31 | } 32 | 33 | @inlinable 34 | public mutating func next() -> UInt64 { 35 | // Adopted from https://github.com/mattgallagher/CwlUtils/blob/0bfc4587d01cfc796b6c7e118fc631333dd8ab33/Sources/CwlUtils/CwlRandom.swift 36 | let x = self.state.1 &* 5 37 | let result = ((x &<< 7) | (x &>> 57)) &* 9 38 | let t = self.state.1 &<< 17 39 | self.state.2 ^= self.state.0 40 | self.state.3 ^= self.state.1 41 | self.state.1 ^= self.state.2 42 | self.state.0 ^= self.state.3 43 | self.state.2 ^= t 44 | self.state.3 = (self.state.3 &<< 45) | (self.state.3 &>> 19) 45 | return result 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Tests/RetryTests/Clocks/AnyClock.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2023 Point-Free 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 | #if canImport(Testing) 24 | final class AnyClock: Clock { 25 | struct Instant: InstantProtocol { 26 | let offset: Duration 27 | 28 | func advanced(by duration: Duration) -> Self { 29 | .init(offset: self.offset + duration) 30 | } 31 | 32 | func duration(to other: Self) -> Duration { 33 | other.offset - self.offset 34 | } 35 | 36 | static func < (lhs: Self, rhs: Self) -> Bool { 37 | lhs.offset < rhs.offset 38 | } 39 | } 40 | 41 | let _minimumResolution: @Sendable () -> Duration 42 | let _now: @Sendable () -> Instant 43 | let _sleep: @Sendable (Instant, Duration?) async throws -> Void 44 | 45 | init(_ clock: C) where C.Instant.Duration == Duration { 46 | let start = clock.now 47 | self._now = { Instant(offset: start.duration(to: clock.now)) } 48 | self._minimumResolution = { clock.minimumResolution } 49 | self._sleep = { try await clock.sleep(until: start.advanced(by: $0.offset), tolerance: $1) } 50 | } 51 | 52 | var minimumResolution: Duration { 53 | self._minimumResolution() 54 | } 55 | 56 | var now: Instant { 57 | self._now() 58 | } 59 | 60 | func sleep(until deadline: Instant, tolerance: Duration? = nil) async throws { 61 | try await self._sleep(deadline, tolerance) 62 | } 63 | } 64 | #endif 65 | -------------------------------------------------------------------------------- /Tests/RetryTests/Clocks/ImmediateClock.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2023 Point-Free 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 | #if canImport(Testing) 24 | import Foundation 25 | 26 | final class ImmediateClock: Clock, @unchecked Sendable where Duration: DurationProtocol, Duration: Hashable { 27 | struct Instant: InstantProtocol { 28 | let offset: Duration 29 | 30 | init(offset: Duration = .zero) { 31 | self.offset = offset 32 | } 33 | 34 | func advanced(by duration: Duration) -> Self { 35 | .init(offset: self.offset + duration) 36 | } 37 | 38 | func duration(to other: Self) -> Duration { 39 | other.offset - self.offset 40 | } 41 | 42 | static func < (lhs: Self, rhs: Self) -> Bool { 43 | lhs.offset < rhs.offset 44 | } 45 | } 46 | 47 | var now: Instant 48 | var minimumResolution: Duration = .zero 49 | let lock = NSLock() 50 | 51 | init(now: Instant = .init()) { 52 | self.now = now 53 | } 54 | 55 | func sleep(until deadline: Instant, tolerance: Duration?) async throws { 56 | try Task.checkCancellation() 57 | self.lock.sync { self.now = deadline } 58 | await Task.megaYield() 59 | } 60 | } 61 | 62 | extension ImmediateClock where Duration == Swift.Duration { 63 | convenience init() { 64 | self.init(now: .init()) 65 | } 66 | } 67 | 68 | extension NSLock { 69 | @inlinable 70 | @discardableResult 71 | func sync(operation: () -> R) -> R { 72 | self.lock() 73 | defer { self.unlock() } 74 | return operation() 75 | } 76 | } 77 | #endif 78 | -------------------------------------------------------------------------------- /Tests/RetryTests/Clocks/UnimplementedClock.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2023 Point-Free 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 | #if canImport(Testing) 24 | import Testing 25 | 26 | struct UnimplementedClock: Clock { 27 | struct Instant: InstantProtocol { 28 | let rawValue: AnyClock.Instant 29 | 30 | func advanced(by duration: Duration) -> Self { 31 | Self(rawValue: self.rawValue.advanced(by: duration)) 32 | } 33 | 34 | func duration(to other: Self) -> Duration { 35 | self.rawValue.duration(to: other.rawValue) 36 | } 37 | 38 | static func < (lhs: Self, rhs: Self) -> Bool { 39 | lhs.rawValue < rhs.rawValue 40 | } 41 | } 42 | 43 | let base: AnyClock 44 | let name: String 45 | let fileID: StaticString 46 | let filePath: StaticString 47 | let line: UInt 48 | let column: UInt 49 | 50 | init( 51 | _ base: C, 52 | name: String = "\(C.self)", 53 | fileID: StaticString = #fileID, 54 | filePath: StaticString = #filePath, 55 | line: UInt = #line, 56 | column: UInt = #column 57 | ) where C.Duration == Duration { 58 | self.base = AnyClock(base) 59 | self.name = name 60 | self.fileID = fileID 61 | self.filePath = filePath 62 | self.line = line 63 | self.column = column 64 | } 65 | 66 | init( 67 | name: String = "Clock", 68 | now: ImmediateClock.Instant = .init(), 69 | fileID: StaticString = #fileID, 70 | filePath: StaticString = #filePath, 71 | line: UInt = #line, 72 | column: UInt = #column 73 | ) { 74 | self.init( 75 | ImmediateClock(now: now), 76 | name: name, 77 | fileID: fileID, 78 | filePath: filePath, 79 | line: line, 80 | column: column 81 | ) 82 | } 83 | 84 | var now: Instant { 85 | Issue.record("Unimplemented: \(self.name).now") 86 | return Instant(rawValue: self.base.now) 87 | } 88 | 89 | var minimumResolution: Duration { 90 | Issue.record("Unimplemented: \(self.name).minimumResolution") 91 | return self.base.minimumResolution 92 | } 93 | 94 | func sleep(until deadline: Instant, tolerance: Duration?) async throws { 95 | Issue.record("Unimplemented: \(self.name).sleep") 96 | try await self.base.sleep(until: deadline.rawValue, tolerance: tolerance) 97 | } 98 | } 99 | 100 | extension UnimplementedClock where Duration == Swift.Duration { 101 | init(name: String = "Clock") { 102 | self.init(name: name, now: .init()) 103 | } 104 | } 105 | #endif 106 | -------------------------------------------------------------------------------- /Tests/RetryTests/RetryTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Testing) 2 | import os 3 | import Retry 4 | import Testing 5 | 6 | struct CustomError: Error { } 7 | 8 | @Test func testBackoffRetryStrategy() async { 9 | 10 | var counter = 0 11 | await #expect(throws: CustomError.self) { 12 | try await retry(maxAttempts: 3, clock: ImmediateClock()) { 13 | counter += 1 14 | throw CustomError() 15 | } 16 | } 17 | 18 | #expect(counter == 3) 19 | } 20 | 21 | @Test func testStopRetryStrategy() async { 22 | 23 | var counter = 0 24 | await #expect(throws: CustomError.self) { 25 | try await retry(maxAttempts: 3, clock: UnimplementedClock()) { 26 | counter += 1 27 | throw CustomError() 28 | } strategy: { _ in 29 | return .stop 30 | } 31 | } 32 | 33 | #expect(counter == 1) 34 | } 35 | 36 | @Test func testCancellationInOperation() async { 37 | 38 | let testClock = TestClock() 39 | let counter = OSAllocatedUnfairLock(initialState: 0) 40 | 41 | let task = Task { 42 | try await retry(maxAttempts: 3, clock: UnimplementedClock()) { 43 | counter.withLock { $0 += 1 } 44 | try await testClock.sleep(until: .init(offset: .seconds(1))) 45 | } 46 | } 47 | 48 | task.cancel() 49 | 50 | await #expect(throws: CancellationError.self) { 51 | try await task.value 52 | } 53 | counter.withLock { value in 54 | #expect(value == 1) 55 | } 56 | } 57 | 58 | @Test func testCancellationInClock() async { 59 | 60 | let testClock = TestClock() 61 | let counter = OSAllocatedUnfairLock(initialState: 0) 62 | 63 | let task = Task { 64 | try await retry(maxAttempts: 3, clock: testClock) { 65 | counter.withLock { $0 += 1 } 66 | throw CustomError() 67 | } strategy: { _ in 68 | return .backoff(.constant(.seconds(1))) 69 | } 70 | } 71 | 72 | await testClock.advance(by: .seconds(0.5)) 73 | task.cancel() 74 | 75 | await #expect(throws: CancellationError.self) { 76 | try await task.value 77 | } 78 | counter.withLock { value in 79 | #expect(value == 1) 80 | } 81 | } 82 | 83 | @Test func testImmediateBackoffStrategy() { 84 | let strategy = BackoffStrategy.none 85 | #expect(strategy.duration(0) == .seconds(0)) 86 | #expect(strategy.duration(1) == .seconds(0)) 87 | #expect(strategy.duration(2) == .seconds(0)) 88 | #expect(strategy.duration(3) == .seconds(0)) 89 | } 90 | 91 | @Test func testConstantBackoffStrategy() { 92 | let strategy = BackoffStrategy.constant(.seconds(1)) 93 | #expect(strategy.duration(0) == .seconds(1)) 94 | #expect(strategy.duration(1) == .seconds(1)) 95 | #expect(strategy.duration(2) == .seconds(1)) 96 | #expect(strategy.duration(3) == .seconds(1)) 97 | } 98 | 99 | @Test func testLinearBackoffStrategy() { 100 | let strategy = BackoffStrategy.linear(a: .seconds(3), b: .seconds(2)) 101 | #expect(strategy.duration(0) == .seconds(2)) 102 | #expect(strategy.duration(1) == .seconds(5)) 103 | #expect(strategy.duration(2) == .seconds(8)) 104 | #expect(strategy.duration(3) == .seconds(11)) 105 | } 106 | 107 | @Test func testExponentialBackoffStrategy() { 108 | let strategy = BackoffStrategy.exponential(a: .seconds(3), b: 2) 109 | #expect(strategy.duration(0) == .seconds(3)) 110 | #expect(strategy.duration(1) == .seconds(6)) 111 | #expect(strategy.duration(2) == .seconds(12)) 112 | #expect(strategy.duration(3) == .seconds(24)) 113 | } 114 | 115 | @Test func testMaxBackoffStrategy() { 116 | let strategy = BackoffStrategy.exponential(a: .seconds(3), b: 2).max(.seconds(10)) 117 | #expect(strategy.duration(0) == .seconds(3)) 118 | #expect(strategy.duration(1) == .seconds(6)) 119 | #expect(strategy.duration(2) == .seconds(10)) 120 | #expect(strategy.duration(3) == .seconds(10)) 121 | } 122 | 123 | @Test func testMinBackoffStrategy() { 124 | let strategy = BackoffStrategy.exponential(a: .seconds(3), b: 2).min(.seconds(7)) 125 | #expect(strategy.duration(0) == .seconds(7)) 126 | #expect(strategy.duration(1) == .seconds(7)) 127 | #expect(strategy.duration(2) == .seconds(12)) 128 | #expect(strategy.duration(3) == .seconds(24)) 129 | } 130 | 131 | @available(iOS 18.0, macOS 15.0, macCatalyst 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) 132 | @Test func testJitterBackoffStrategy() { 133 | let strategy = BackoffStrategy.exponential(a: .seconds(3), b: 2).jitter(using: Xoshiro(seed: 1)) 134 | #expect(strategy.duration(0) == .init(secondsComponent: 2, attosecondsComponent: 162894527200761519)) 135 | #expect(strategy.duration(1) == .init(secondsComponent: 5, attosecondsComponent: 574149987820172283)) 136 | #expect(strategy.duration(2) == .init(secondsComponent: 0, attosecondsComponent: 699421850917031809)) 137 | #expect(strategy.duration(3) == .init(secondsComponent: 8, attosecondsComponent: 988773637185598212)) 138 | } 139 | #endif 140 | -------------------------------------------------------------------------------- /Tests/RetryTests/Clocks/TestClock.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2023 Point-Free 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 | #if canImport(Testing) 24 | import Testing 25 | import Foundation 26 | 27 | final class TestClock: Clock, @unchecked Sendable { 28 | struct Instant: InstantProtocol { 29 | let offset: Duration 30 | 31 | init(offset: Duration = .zero) { 32 | self.offset = offset 33 | } 34 | 35 | func advanced(by duration: Duration) -> Self { 36 | .init(offset: self.offset + duration) 37 | } 38 | 39 | func duration(to other: Self) -> Duration { 40 | other.offset - self.offset 41 | } 42 | 43 | static func < (lhs: Self, rhs: Self) -> Bool { 44 | lhs.offset < rhs.offset 45 | } 46 | } 47 | 48 | var minimumResolution: Duration = .zero 49 | var now: Instant 50 | 51 | let lock = NSRecursiveLock() 52 | var suspensions: 53 | [( 54 | id: UUID, 55 | deadline: Instant, 56 | continuation: AsyncThrowingStream.Continuation 57 | )] = [] 58 | 59 | init(now: Instant = .init()) { 60 | self.now = now 61 | } 62 | 63 | func sleep(until deadline: Instant, tolerance: Duration? = nil) async throws { 64 | try Task.checkCancellation() 65 | let id = UUID() 66 | do { 67 | let stream: AsyncThrowingStream? = self.lock.sync { 68 | guard deadline >= self.now 69 | else { 70 | return nil 71 | } 72 | return AsyncThrowingStream { continuation in 73 | self.suspensions.append((id: id, deadline: deadline, continuation: continuation)) 74 | } 75 | } 76 | guard let stream = stream 77 | else { return } 78 | for try await _ in stream {} 79 | try Task.checkCancellation() 80 | } catch is CancellationError { 81 | self.lock.sync { self.suspensions.removeAll(where: { $0.id == id }) } 82 | throw CancellationError() 83 | } catch { 84 | throw error 85 | } 86 | } 87 | 88 | func checkSuspension() async throws { 89 | await Task.megaYield() 90 | guard self.lock.sync(operation: { self.suspensions.isEmpty }) 91 | else { throw SuspensionError() } 92 | } 93 | 94 | func advance(by duration: Duration = .zero) async { 95 | await self.advance(to: self.lock.sync(operation: { self.now.advanced(by: duration) })) 96 | } 97 | 98 | func advance(to deadline: Instant) async { 99 | while self.lock.sync(operation: { self.now <= deadline }) { 100 | await Task.megaYield() 101 | let `return` = { 102 | self.lock.lock() 103 | self.suspensions.sort { $0.deadline < $1.deadline } 104 | 105 | guard 106 | let next = self.suspensions.first, 107 | deadline >= next.deadline 108 | else { 109 | self.now = deadline 110 | self.lock.unlock() 111 | return true 112 | } 113 | 114 | self.now = next.deadline 115 | self.suspensions.removeFirst() 116 | self.lock.unlock() 117 | next.continuation.finish() 118 | return false 119 | }() 120 | 121 | if `return` { 122 | await Task.megaYield() 123 | return 124 | } 125 | } 126 | await Task.megaYield() 127 | } 128 | 129 | func run( 130 | timeout duration: Swift.Duration = .milliseconds(500), 131 | fileID: StaticString = #fileID, 132 | filePath: StaticString = #filePath, 133 | line: UInt = #line, 134 | column: UInt = #column 135 | ) async { 136 | do { 137 | try await withThrowingTaskGroup(of: Void.self) { group in 138 | group.addTask { 139 | try await Task.sleep(until: .now.advanced(by: duration), clock: .continuous) 140 | for suspension in self.suspensions { 141 | suspension.continuation.finish(throwing: CancellationError()) 142 | } 143 | throw CancellationError() 144 | } 145 | group.addTask { 146 | await Task.megaYield() 147 | while let deadline = self.lock.sync(operation: { self.suspensions.first?.deadline }) { 148 | try Task.checkCancellation() 149 | await self.advance(by: self.lock.sync(operation: { self.now.duration(to: deadline) })) 150 | } 151 | } 152 | try await group.next() 153 | group.cancelAll() 154 | } 155 | } catch { 156 | Issue.record( 157 | """ 158 | Expected all sleeps to finish, but some are still suspending after \(duration). 159 | 160 | There are sleeps suspending. This could mean you are not advancing the test clock far \ 161 | enough for your feature to execute its logic, or there could be a bug in your feature's \ 162 | logic. 163 | 164 | You can also increase the timeout of 'run' to be greater than \(duration). 165 | """ 166 | ) 167 | } 168 | } 169 | } 170 | 171 | struct SuspensionError: Error {} 172 | 173 | extension Task where Success == Never, Failure == Never { 174 | static func megaYield(count: Int = 20) async { 175 | for _ in 0...detached(priority: .background) { await Task.yield() }.value 177 | } 178 | } 179 | } 180 | 181 | extension NSRecursiveLock { 182 | @inlinable 183 | @discardableResult 184 | func sync(operation: () -> R) -> R { 185 | self.lock() 186 | defer { self.unlock() } 187 | return operation() 188 | } 189 | } 190 | 191 | extension TestClock where Duration == Swift.Duration { 192 | convenience init() { 193 | self.init(now: .init()) 194 | } 195 | } 196 | #endif 197 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Retry 2 | A retry algorithm for Swift Concurrency 3 | 4 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fph1ps%2Fswift-concurrency-retry%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/ph1ps/swift-concurrency-retry) 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fph1ps%2Fswift-concurrency-retry%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/ph1ps/swift-concurrency-retry) 6 | 7 | ## Details 8 | 9 | The library comes with two free functions, one with a generic clock and another one which uses the `ContinuousClock` as default. 10 | ```swift 11 | public func retry( 12 | maxAttempts: Int = 3, 13 | tolerance: C.Duration? = nil, 14 | clock: C, 15 | isolation: isolated (any Actor)? = #isolation, 16 | operation: () async throws(E) -> sending R, 17 | strategy: (E) -> RetryStrategy = { _ in .backoff(.none) } 18 | ) async throws -> R where C: Clock, E: Error { ... } 19 | 20 | public func retry( 21 | maxAttempts: Int = 3, 22 | tolerance: ContinuousClock.Duration? = nil, 23 | isolation: isolated (any Actor)? = #isolation, 24 | operation: () async throws(E) -> sending R, 25 | strategy: (E) -> RetryStrategy = { _ in .backoff(.none) } 26 | ) async throws -> R where E: Error { ... } 27 | ``` 28 | 29 | The `retry` function performs an asynchronous operation and retries it up to a specified number of attempts if it encounters an error. You can define a custom retry strategy based on the type of error encountered, and control the delay between attempts with a `BackoffStrategy`. 30 | 31 | - Parameters: 32 | - `maxAttempts`: The maximum number of attempts to retry the operation. Defaults to 3. 33 | - `tolerance`: An optional tolerance for the delay duration to account for clock imprecision. Defaults to `nil`. 34 | - `isolation`: The inherited actor isolation. 35 | - `operation`: The asynchronous operation to perform. This function will retry the operation in case of error, based on the retry strategy provided. 36 | - `strategy`: A closure that determines the `RetryStrategy` for handling retries based on the error type. Defaults to `.backoff(.none)`, meaning no delay between retries. 37 | 38 | - Returns: The result of the operation, if successful within the allowed number of attempts. 39 | - Throws: Rethrows the last encountered error if all retry attempts fail or if the retry strategy specifies stopping retries or any error thrown by `clock` 40 | - Precondition: `maxAttempts` must be greater than 0. 41 | 42 | ### Backoff 43 | This library ships with 7 prebuilt backoff strategies: 44 | 45 | #### None 46 | A backoff strategy with no delay between attempts. 47 | 48 | This strategy enforces a zero-duration delay, making retries immediate. It’s suitable for situations where retries should happen as soon as possible without any waiting period. 49 | 50 | $`f(x) = 0`$ where `x` is the current attempt. 51 | ```swift 52 | extension BackoffStrategy { 53 | public static var none: Self { ... } 54 | } 55 | ``` 56 | 57 | #### Constant 58 | A backoff strategy with a constant delay between each attempt. 59 | 60 | This strategy applies a fixed, unchanging delay between retries, regardless of attempt count. It’s ideal for retry patterns where uniform intervals between attempts are desired. 61 | 62 | $`f(x) = c`$ where `x` is the current attempt. 63 | ```swift 64 | extension BackoffStrategy { 65 | public static func constant(_ c: C.Duration) -> Self { ... } 66 | } 67 | ``` 68 | 69 | #### Linear 70 | A backoff strategy with a linearly increasing delay between attempts. 71 | 72 | This strategy gradually increases the delay after each retry, beginning with an initial delay and scaling linearly. Useful for scenarios where delays need to increase consistently over time. 73 | 74 | $`f(x) = ax + b`$ where `x` is the current attempt. 75 | ```swift 76 | extension BackoffStrategy { 77 | public static func linear(a: C.Duration, b: C.Duration) -> Self { ... } 78 | } 79 | ``` 80 | 81 | #### Exponential 82 | A backoff strategy with an exponentially increasing delay between attempts. 83 | 84 | This strategy grows the delay exponentially, starting with an initial duration and applying a multiplicative factor after each retry. Suitable for cases where retries should become increasingly sparse. 85 | 86 | $`f(x) = a * b^x`$ where `x` is the current attempt. 87 | 88 | ```swift 89 | extension BackoffStrategy where C.Duration == Duration { 90 | public static func exponential(a: C.Duration, b: Double) -> Self { ... } 91 | } 92 | ``` 93 | 94 | #### Minimum 95 | Enforces a minimum delay duration for this backoff strategy. 96 | 97 | This method ensures the backoff delay is never shorter than the defined minimum duration, helping to maintain a baseline wait time between retries. This retains the original backoff pattern but raises durations below the specified threshold to the minimum value. 98 | 99 | $`g(x) = max(f(x), m)`$ where `x` is the current attempt and `f(x)` the base backoff strategy. 100 | ```swift 101 | extension BackoffStrategy { 102 | public func min(_ m: C.Duration) -> Self { ... } 103 | } 104 | ``` 105 | 106 | #### Maximum 107 | Limits the maximum delay duration for this backoff strategy. 108 | 109 | This method ensures the backoff delay does not exceed the defined maximum duration, helping to avoid overly long wait times between retries. This retains the original backoff pattern up to the specified cap. 110 | 111 | $`g(x) = min(f(x), M)`$ where `x` is the current attempt and `f(x)` the base backoff strategy. 112 | ```swift 113 | extension BackoffStrategy { 114 | public func max(_ M: C.Duration) -> Self { ... } 115 | } 116 | ``` 117 | 118 | #### Jitter 119 | Applies jitter to the delay duration, introducing randomness into the backoff interval. 120 | 121 | This method randomizes the delay for each retry attempt within a range from zero up to the base duration. Jitter can help reduce contention when multiple sources retry concurrently in distributed systems. 122 | 123 | $`g(x) = random[0, f(x)[`$ where `x` is the current attempt and `f(x)` the base backoff strategy. 124 | ```swift 125 | @available(iOS 18.0, macOS 15.0, macCatalyst 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) 126 | extension BackoffStrategy where C.Duration == Duration { 127 | public func jitter(using generator: T = SystemRandomNumberGenerator()) -> Self where T: RandomNumberGenerator { ... } 128 | } 129 | ``` 130 | 131 | ## Examples 132 | To fully understand this, let's illustrate 3 customizations of this function with `URLSession.shared.data(from: url)` as example operation: 133 | 134 | ### Customization 1 135 | Use the defaults: 136 | ```swift 137 | let (data, response) = try await retry { 138 | try await URLSession.shared.data(from: url) 139 | } 140 | ``` 141 | If the operation succeeds, the result is returned immediately. 142 | However if any error occurs, the operation will be tried 2 more times, as the default maximum attempts are 3. 143 | Between the consecutive attempts, there will be no delay, as, again, the default retry strategy is to not add any backoff. 144 | 145 | ### Customization 2 146 | Use a custom backoff strategy and custom amount of attempts: 147 | ```swift 148 | let (data, response) = try await retry(maxAttempts: 5) { 149 | try await URLSession.shared.data(from: url) 150 | } strategy: { _ in 151 | return .backoff(.constant(.seconds(2))) 152 | } 153 | ``` 154 | Similarly to the first customization, if any error occurs, the operation will be tried 4 more times. 155 | However, between consecutive attempts, it will wait for 2 seconds until it will run operation again. 156 | 157 | ### Customization 3 158 | Use a custom backoff and retry strategy: 159 | ```swift 160 | struct TooManyRequests: Error { 161 | let retryAfter: Double 162 | } 163 | 164 | let (data, response) = try await retry { 165 | let (data, response) = try await URLSession.shared.data(from: url) 166 | 167 | if 168 | let response = response as? HTTPURLResponse, 169 | let retryAfter = response.value(forHTTPHeaderField: "Retry-After").flatMap(Double.init), 170 | response.statusCode == 429 171 | { 172 | throw TooManyRequests(retryAfter: retryAfter) 173 | } 174 | 175 | return (data, response) 176 | } strategy: { error in 177 | if let error = error as? TooManyRequests { 178 | return .backoff(.constant(.seconds(error.retryAfter))) 179 | } else { 180 | return .stop 181 | } 182 | } 183 | ``` 184 | A common behavior for servers is to return an HTTP status code 429 with a recommended backoff if the load gets too high. 185 | Contrary to the first two examples, we only retry if the error is of type `TooManyRequests`, any other errors will be rethrown and retrying is stopped. 186 | The constant backoff is dynamically fetched from the custom error and passed as seconds. 187 | 188 | ## Improvements 189 | - Only have one free function with a default expression of `ContinuousClock` for the `clock` parameter. 190 | - Blocked by: https://github.com/swiftlang/swift/issues/72199 191 | -------------------------------------------------------------------------------- /Sources/Retry/Retry.swift: -------------------------------------------------------------------------------- 1 | import _PowShims 2 | 3 | /// A generic strategy for implementing a backoff mechanism. 4 | /// 5 | /// `BackoffStrategy` defines how to calculate a delay duration based on the number of retry attempts. 6 | /// This can be useful for implementing retry policies with increasing delays between attempts. 7 | public struct BackoffStrategy where C: Clock { 8 | public let duration: (Int) -> C.Duration 9 | public init(duration: @escaping (_ attempt: Int) -> C.Duration) { self.duration = duration } 10 | } 11 | 12 | extension BackoffStrategy { 13 | /// A backoff strategy with no delay between attempts. 14 | /// 15 | /// This strategy enforces a zero-duration delay, making retries immediate. It’s suitable for situations 16 | /// where retries should happen as soon as possible without any waiting period. 17 | /// - Note: `f(x) = 0` where `x` is the current attempt. 18 | public static var none: Self { 19 | .init { _ in .zero } 20 | } 21 | 22 | /// A backoff strategy with a constant delay between each attempt. 23 | /// 24 | /// This strategy applies a fixed, unchanging delay between retries, regardless of attempt count. It’s ideal 25 | /// for retry patterns where uniform intervals between attempts are desired. 26 | /// - Parameter c: The constant duration to wait between each retry attempt. 27 | /// - Note: `f(x) = c` where `x` is the current attempt. 28 | public static func constant(_ c: C.Duration) -> Self { 29 | .init { _ in c } 30 | } 31 | 32 | /// A backoff strategy with a linearly increasing delay between attempts. 33 | /// 34 | /// This strategy gradually increases the delay after each retry, beginning with an initial delay and scaling 35 | /// linearly. Useful for scenarios where delays need to increase consistently over time. 36 | /// - Parameters: 37 | /// - a: The incremental delay increase applied with each retry attempt. 38 | /// - b: The base delay duration added to each retry. 39 | /// - Note: `f(x) = ax + b` where `x` is the current attempt. 40 | public static func linear(a: C.Duration, b: C.Duration) -> Self { 41 | .init { attempt in a * attempt + b } 42 | } 43 | 44 | /// A backoff strategy with an exponentially increasing delay between attempts. 45 | /// 46 | /// This strategy grows the delay exponentially, starting with an initial duration and applying a multiplicative 47 | /// factor after each retry. Suitable for cases where retries should become increasingly sparse. 48 | /// - Parameters: 49 | /// - a: The base delay duration applied before any retry attempt. 50 | /// - b: The growth factor applied at each retry attempt. 51 | /// - Note: `f(x) = a * b^x` where `x` is the current attempt. 52 | public static func exponential(a: C.Duration, b: Double) -> Self where C.Duration == Duration { 53 | .init { attempt in a * pow(b, Double(attempt)) } 54 | } 55 | 56 | /// Enforces a minimum delay duration for this backoff strategy. 57 | /// 58 | /// This method ensures the backoff delay is never shorter than the defined minimum duration, helping to 59 | /// maintain a baseline wait time between retries. This retains the original backoff pattern but raises 60 | /// durations below the specified threshold to the minimum value. 61 | /// - Parameter m: The minimum allowable duration for each retry attempt. 62 | /// - Note: `g(x) = max(f(x), m)` where `x` is the current attempt and `f(x)` the base backoff strategy. 63 | public func min(_ m: C.Duration) -> Self { 64 | .init { attempt in Swift.max(duration(attempt), m) } 65 | } 66 | 67 | /// Limits the maximum delay duration for this backoff strategy. 68 | /// 69 | /// This method ensures the backoff delay does not exceed the defined maximum duration, helping to avoid 70 | /// overly long wait times between retries. This retains the original backoff pattern up to the specified cap. 71 | /// - Parameter M: The maximum allowable duration for each retry attempt. 72 | /// - Note: `g(x) = min(f(x), M)` where `x` is the current attempt and `f(x)` the base backoff strategy. 73 | public func max(_ M: C.Duration) -> Self { 74 | .init { attempt in Swift.min(duration(attempt), M) } 75 | } 76 | 77 | /// Applies jitter to the delay duration, introducing randomness into the backoff interval. 78 | /// 79 | /// This method randomizes the delay for each retry attempt within a range from zero up to the base duration. 80 | /// Jitter can help reduce contention when multiple sources retry concurrently in distributed systems. 81 | /// - Parameter generator: A custom random number generator conforming to the `RandomNumberGenerator` protocol. Defaults to `SystemRandomNumberGenerator`. 82 | /// - Note: `g(x) = random[0, f(x)[` where `x` is the current attempt and `f(x)` the base backoff strategy. 83 | @available(iOS 18.0, macOS 15.0, macCatalyst 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) 84 | public func jitter(using generator: T = SystemRandomNumberGenerator()) -> Self where T: RandomNumberGenerator, C.Duration == Duration { 85 | var generator = generator 86 | let attosecondsPerSecond: Int128 = 1_000_000_000_000_000_000 87 | return .init { attempt in 88 | let duration = duration(attempt) 89 | let (seconds, attoseconds) = Int128.random( 90 | in: 0..<(Int128(duration.components.seconds) * attosecondsPerSecond + Int128(duration.components.attoseconds)), 91 | using: &generator 92 | ).quotientAndRemainder(dividingBy: attosecondsPerSecond) 93 | return .init(secondsComponent: Int64(seconds), attosecondsComponent: Int64(attoseconds)) 94 | } 95 | } 96 | } 97 | 98 | /// A strategy for managing retry attempts, with options for either backoff-based retries or immediate termination. 99 | /// 100 | /// `RetryStrategy` allows you to define a retry policy that can either use a customizable backoff strategy 101 | /// to manage retry timing, or stop retrying entirely after a given point. 102 | public enum RetryStrategy where C: Clock { 103 | case backoff(BackoffStrategy) 104 | case stop 105 | } 106 | 107 | /// Executes an asynchronous operation with a retry mechanism, applying a backoff strategy between attempts. 108 | /// 109 | /// The `retry` function performs an asynchronous operation and retries it up to a specified number of attempts 110 | /// if it encounters an error. You can define a custom retry strategy based on the type of error encountered, 111 | /// and control the delay between attempts with a `BackoffStrategy`. 112 | /// 113 | /// ## Examples 114 | /// To fully understand this, let's illustrate the 3 customizations of this function with `URLSession.shared.data(from: url)` as example operation: 115 | /// 116 | /// ### Customization 1 117 | /// Use the defaults: 118 | /// ```swift 119 | /// let (data, response) = try await retry { 120 | /// try await URLSession.shared.data(from: url) 121 | /// } 122 | /// ``` 123 | /// If the operation succeeds, the result is returned immediately. 124 | /// However if any error occurs, the operation will be tried 2 more times, as the default maximum attempts are 3. 125 | /// Between the consecutive attempts, there will be no delay, as, again, the default retry strategy is to not add any backoff. 126 | /// 127 | /// ### Customization 2 128 | /// Use a custom backoff strategy and custom amount of attempts: 129 | /// ```swift 130 | /// let (data, response) = try await retry(maxAttempts: 5) { 131 | /// try await URLSession.shared.data(from: url) 132 | /// } strategy: { _ in 133 | /// return .backoff(.constant(.seconds(2))) 134 | /// } 135 | /// ``` 136 | /// Similarly to the first customization, if any error occurs, the operation will be tried 4 more times. 137 | /// However, between consecutive attempts, it will wait for 2 seconds until it will run operation again. 138 | /// 139 | /// ### Customization 3 140 | /// Use a custom backoff and retry strategy: 141 | /// ```swift 142 | /// struct TooManyRequests: Error { 143 | /// let retryAfter: Double 144 | /// } 145 | /// 146 | /// let (data, response) = try await retry { 147 | /// let (data, response) = try await URLSession.shared.data(from: url) 148 | /// 149 | /// if 150 | /// let response = response as? HTTPURLResponse, 151 | /// let retryAfter = response.value(forHTTPHeaderField: "Retry-After").flatMap(Double.init), 152 | /// response.statusCode == 429 153 | /// { 154 | /// throw TooManyRequests(retryAfter: retryAfter) 155 | /// } 156 | /// 157 | /// return (data, response) 158 | /// } strategy: { error in 159 | /// if let error = error as? TooManyRequests { 160 | /// return .backoff(.constant(.seconds(error.retryAfter))) 161 | /// } else { 162 | /// return .stop 163 | /// } 164 | /// } 165 | /// ``` 166 | /// A common behavior for servers is to return an HTTP status code 429 with a recommended backoff if the load gets too high. 167 | /// Contrary to the first two examples, we only retry if the error is of type `TooManyRequests`, any other errors will be rethrown and retrying is stopped. 168 | /// The constant backoff is dynamically fetched from the custom error and passed as seconds. 169 | /// 170 | /// - Parameters: 171 | /// - maxAttempts: The maximum number of attempts to retry the operation. Defaults to 3. 172 | /// - tolerance: An optional tolerance for the delay duration to account for clock imprecision. Defaults to `nil`. 173 | /// - clock: The clock used to wait for delays between retries. 174 | /// - isolation: The inherited actor isolation. 175 | /// - operation: The asynchronous operation to perform. This function will retry the operation in case of error, based on the retry strategy provided. 176 | /// - strategy: A closure that determines the `RetryStrategy` for handling retries based on the error type. Defaults to `.backoff(.none)`, meaning no delay between retries. 177 | /// 178 | /// - Returns: The result of the operation, if successful within the allowed number of attempts. 179 | /// - Throws: Rethrows the last encountered error if all retry attempts fail or if the retry strategy specifies stopping retries or any error thrown by `clock` 180 | /// - Precondition: `maxAttempts` must be greater than 0. 181 | public func retry( 182 | maxAttempts: Int = 3, 183 | tolerance: C.Duration? = nil, 184 | clock: C, 185 | isolation: isolated (any Actor)? = #isolation, 186 | operation: () async throws(E) -> sending R, 187 | strategy: (E) -> RetryStrategy = { _ in .backoff(.none) } 188 | ) async throws -> R where C: Clock, E: Error { 189 | 190 | precondition(maxAttempts > 0, "Retry must have at least one attempt") 191 | 192 | for attempt in 0..( 284 | maxAttempts: Int = 3, 285 | tolerance: ContinuousClock.Duration? = nil, 286 | isolation: isolated (any Actor)? = #isolation, 287 | operation: () async throws(E) -> sending R, 288 | strategy: (E) -> RetryStrategy = { _ in .backoff(.none) } 289 | ) async throws -> R where E: Error { 290 | try await retry(maxAttempts: maxAttempts, tolerance: tolerance, clock: ContinuousClock(), operation: operation, strategy: strategy) 291 | } 292 | --------------------------------------------------------------------------------