Skip to content

Commit 766af43

Browse files
authored
feat: introduce chain delay strategy (#79)
1 parent 801c85e commit 766af43

5 files changed

Lines changed: 337 additions & 1 deletion

File tree

README.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,45 @@ do {
148148
- Attempt 4: After 2 seconds
149149
- Attempt 5: After 2 seconds
150150

151+
### Linear Strategy
152+
153+
Delays grow proportionally with each attempt — a middle ground between constant and exponential:
154+
155+
```swift
156+
import Typhoon
157+
158+
// Retry up to 4 times with linearly increasing delays
159+
let service = RetryPolicyService(
160+
strategy: .linear(retry: 3, duration: .seconds(1))
161+
)
162+
```
163+
164+
**Retry Timeline:**
165+
- Attempt 1: Immediate
166+
- Attempt 2: After 1 second (1 × 1)
167+
- Attempt 3: After 2 seconds (1 × 2)
168+
- Attempt 4: After 3 seconds (1 × 3)
169+
170+
### Fibonacci Strategy
171+
172+
Delays follow the Fibonacci sequence — grows faster than linear but slower than exponential:
173+
174+
```swift
175+
import Typhoon
176+
177+
let service = RetryPolicyService(
178+
strategy: .fibonacci(retry: 5, duration: .seconds(1))
179+
)
180+
```
181+
182+
**Retry Timeline:**
183+
- Attempt 1: Immediate
184+
- Attempt 2: After 1 second
185+
- Attempt 3: After 1 second
186+
- Attempt 4: After 2 seconds
187+
- Attempt 5: After 3 seconds
188+
- Attempt 6: After 5 seconds
189+
151190
### Exponential Strategy
152191

153192
Ideal for avoiding overwhelming a failing service by progressively increasing wait times:
@@ -159,6 +198,7 @@ import Typhoon
159198
let service = RetryPolicyService(
160199
strategy: .exponential(
161200
retry: 3,
201+
jitterFactor: 0,
162202
multiplier: 2.0,
163203
duration: .seconds(1)
164204
)
@@ -211,6 +251,71 @@ do {
211251
- Reduces load spikes on recovering services
212252
- Improves overall system resilience
213253

254+
### Custom Strategy
255+
256+
Provide your own delay logic by implementing `IRetryDelayStrategy`:
257+
258+
```swift
259+
import Typhoon
260+
261+
struct QuadraticDelayStrategy: IRetryDelayStrategy {
262+
func delay(forRetry retries: UInt) -> UInt64? {
263+
let seconds = Double(retries * retries) // 0s, 1s, 4s, 9s...
264+
return UInt64(seconds * 1_000_000_000)
265+
}
266+
}
267+
268+
let service = RetryPolicyService(
269+
strategy: .custom(retry: 4, strategy: QuadraticDelayStrategy())
270+
)
271+
```
272+
273+
### Chain Strategy
274+
275+
Combines multiple strategies executed sequentially. Each strategy runs independently with its own delay logic, making it ideal for phased retry approaches — e.g. react quickly first, then back off gradually.
276+
277+
```swift
278+
import Typhoon
279+
280+
let service = RetryPolicyService(
281+
strategy: .chain([
282+
// Phase 1: 3 quick attempts with constant delay
283+
.init(retries: 3, strategy: ConstantDelayStrategy(duration: .milliseconds(100))),
284+
// Phase 2: 3 slower attempts with exponential backoff
285+
.init(retries: 3, strategy: ExponentialDelayStrategy(
286+
duration: .seconds(1),
287+
multiplier: 2.0,
288+
jitterFactor: 0.1,
289+
maxInterval: .seconds(60)
290+
))
291+
])
292+
)
293+
294+
do {
295+
let result = try await service.retry {
296+
try await fetchDataFromAPI()
297+
}
298+
} catch {
299+
print("Failed after all phases")
300+
}
301+
```
302+
303+
**Retry Timeline:**
304+
```
305+
Attempt 1: immediate
306+
Attempt 2: 100ms ┐
307+
Attempt 3: 100ms ├─ Phase 1: Constant
308+
Attempt 4: 100ms ┘
309+
Attempt 5: 1s ┐
310+
Attempt 6: 2s ├─ Phase 2: Exponential
311+
Attempt 7: 4s ┘
312+
```
313+
314+
The total retry count is calculated automatically from the sum of all entries — no need to specify it manually.
315+
316+
Each strategy in the chain uses **local indexing**, meaning every phase starts its delay calculation from zero. This ensures each strategy behaves predictably regardless of its position in the chain.
317+
318+
214319
## Common Use Cases
215320

216321
### Network Requests
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
//
2+
// Typhoon
3+
// Copyright © 2026 Space Code. All rights reserved.
4+
//
5+
6+
/// A delay strategy that chains multiple strategies sequentially.
7+
///
8+
/// Exhausts each strategy in order before moving to the next.
9+
///
10+
/// ### Example
11+
/// ```swift
12+
/// let strategy = ChainDelayStrategy(strategies: [
13+
/// (retries: 3, strategy: ConstantDelayStrategy(duration: .milliseconds(100))),
14+
/// (retries: 2, strategy: ExponentialDelayStrategy(duration: .seconds(1), ...))
15+
/// ])
16+
/// ```
17+
public struct ChainDelayStrategy: IRetryDelayStrategy {
18+
// MARK: - Types
19+
20+
/// Represents a single retry configuration entry.
21+
public struct Entry: Sendable {
22+
/// The maximum number of retry attempts.
23+
public let retries: UInt
24+
25+
/// The delay strategy that determines how long to wait
26+
/// between retry attempts.
27+
public let strategy: IRetryDelayStrategy
28+
29+
/// Creates a new retry configuration entry.
30+
///
31+
/// - Parameters:
32+
/// - retries: The maximum number of retry attempts.
33+
/// - strategy: The delay strategy applied between attempts.
34+
public init(retries: UInt, strategy: IRetryDelayStrategy) {
35+
self.retries = retries
36+
self.strategy = strategy
37+
}
38+
}
39+
40+
// MARK: - Properties
41+
42+
/// Ordered retry configuration entries.
43+
private let entries: [Entry]
44+
45+
/// The total number of retries supported by this chain.
46+
public let totalRetries: UInt
47+
48+
// MARK: - Initialization
49+
50+
/// Creates a chained delay strategy.
51+
///
52+
/// - Parameter entries: Ordered retry configuration entries.
53+
/// Strategies are evaluated in the order provided.
54+
public init(entries: [Entry]) {
55+
self.entries = entries
56+
totalRetries = entries.reduce(0) { $0 + $1.retries }
57+
}
58+
59+
// MARK: - IRetryDelayStrategy
60+
61+
/// Returns the delay for a given retry index.
62+
public func delay(forRetry retries: UInt) -> UInt64? {
63+
var offset: UInt = 0
64+
65+
for entry in entries {
66+
if retries < offset + entry.retries {
67+
let localRetry = retries - offset
68+
return entry.strategy.delay(forRetry: localRetry)
69+
}
70+
offset += entry.retries
71+
}
72+
73+
return nil
74+
}
75+
}

Sources/Typhoon/Classes/Strategy/RetryPolicyStrategy.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ public enum RetryPolicyStrategy: Sendable {
7878
}
7979
}
8080

81+
// MARK: Strategy
82+
8183
extension RetryPolicyStrategy {
8284
var strategy: IRetryDelayStrategy {
8385
switch self {
@@ -99,3 +101,19 @@ extension RetryPolicyStrategy {
99101
}
100102
}
101103
}
104+
105+
// MARK: - Chain
106+
107+
public extension RetryPolicyStrategy {
108+
/// Creates a `.custom` retry strategy using a chained delay strategy.
109+
///
110+
/// The total number of retries is automatically calculated
111+
/// as the sum of all provided entries.
112+
///
113+
/// - Parameter entries: Ordered delay strategy entries.
114+
/// - Returns: A `.custom` strategy wrapping `ChainDelayStrategy`.
115+
static func chain(_ entries: [ChainDelayStrategy.Entry]) -> RetryPolicyStrategy {
116+
let chain = ChainDelayStrategy(entries: entries)
117+
return .custom(retry: chain.totalRetries, strategy: chain)
118+
}
119+
}

Tests/TyphoonTests/UnitTests/RetryPolicyServiceTests.swift

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// Copyright © 2023 Space Code. All rights reserved.
44
//
55

6-
import Typhoon
6+
@testable import Typhoon
77
import XCTest
88

99
// MARK: - RetryPolicyServiceTests
@@ -360,6 +360,32 @@ final class RetryPolicyServiceTests: XCTestCase {
360360
let attempts = await counter.getValue()
361361
XCTAssertEqual(attempts, .defaultRetryCount + 1)
362362
}
363+
364+
func test_thatChainDelayStrategy_worksWithRetryPolicyService() async throws {
365+
// given
366+
let counter = Counter()
367+
let service = RetryPolicyService(
368+
strategy: .custom(
369+
retry: 5,
370+
strategy: ChainDelayStrategy(entries: [
371+
.init(retries: 3, strategy: ConstantDelayStrategy(duration: .nanoseconds(1))),
372+
.init(retries: 2, strategy: ConstantDelayStrategy(duration: .nanoseconds(1))),
373+
])
374+
)
375+
)
376+
377+
// when
378+
do {
379+
_ = try await service.retry {
380+
_ = await counter.increment()
381+
throw URLError(.unknown)
382+
}
383+
} catch {}
384+
385+
// then
386+
let attempts = await counter.getValue()
387+
XCTAssertEqual(attempts, 6)
388+
}
363389
}
364390

365391
// MARK: - Counter

Tests/TyphoonTests/UnitTests/RetrySequenceTests.swift

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,118 @@ final class RetrySequenceTests: XCTestCase {
227227
XCTAssertEqual(result[7], 128, accuracy: 13)
228228
}
229229

230+
func test_thatChainDelayStrategy_returnsDelaysFromFirstStrategy() {
231+
// given
232+
let sut = ChainDelayStrategy(entries: [
233+
.init(retries: 3, strategy: ConstantDelayStrategy(duration: .seconds(1))),
234+
.init(retries: 2, strategy: ConstantDelayStrategy(duration: .seconds(5))),
235+
])
236+
237+
// then
238+
XCTAssertNotNil(sut.delay(forRetry: 0))
239+
XCTAssertNotNil(sut.delay(forRetry: 1))
240+
XCTAssertNotNil(sut.delay(forRetry: 2))
241+
}
242+
243+
func test_thatChainDelayStrategy_switchesToSecondStrategy_afterFirstExhausted() {
244+
// given
245+
let firstDelay: UInt64 = 1_000_000_000
246+
let secondDelay: UInt64 = 5_000_000_000
247+
248+
let sut = ChainDelayStrategy(entries: [
249+
.init(retries: 3, strategy: ConstantDelayStrategy(duration: .seconds(1))),
250+
.init(retries: 2, strategy: ConstantDelayStrategy(duration: .seconds(5))),
251+
])
252+
253+
// then
254+
XCTAssertEqual(sut.delay(forRetry: 0), firstDelay)
255+
XCTAssertEqual(sut.delay(forRetry: 1), firstDelay)
256+
XCTAssertEqual(sut.delay(forRetry: 2), firstDelay)
257+
XCTAssertEqual(sut.delay(forRetry: 3), secondDelay)
258+
XCTAssertEqual(sut.delay(forRetry: 4), secondDelay)
259+
}
260+
261+
func test_thatChainDelayStrategy_returnsNil_whenAllStrategiesExhausted() {
262+
// given
263+
let sut = ChainDelayStrategy(entries: [
264+
.init(retries: 3, strategy: ConstantDelayStrategy(duration: .seconds(1))),
265+
.init(retries: 2, strategy: ConstantDelayStrategy(duration: .seconds(5))),
266+
])
267+
268+
// then
269+
XCTAssertNil(sut.delay(forRetry: 5))
270+
}
271+
272+
func test_thatRetrySequenceCreatesASequence_whenStrategyIsChainWithDifferentDelays() {
273+
// given
274+
let sequence = RetrySequence(
275+
strategy: .chain([
276+
.init(retries: 3, strategy: ConstantDelayStrategy(duration: .nanoseconds(1))),
277+
.init(retries: 3, strategy: ExponentialDelayStrategy(
278+
duration: .nanoseconds(1),
279+
multiplier: 2.0,
280+
jitterFactor: 0.0,
281+
maxInterval: nil
282+
)),
283+
])
284+
)
285+
286+
// when
287+
let result: [UInt64] = sequence.map { $0 }
288+
289+
// then
290+
XCTAssertEqual(result, [1, 1, 1, 1, 2, 4])
291+
}
292+
293+
func test_thatChainStrategy_automaticallyCalculatesTotalRetries() {
294+
// given
295+
let entries: [ChainDelayStrategy.Entry] = [
296+
.init(retries: 3, strategy: ConstantDelayStrategy(duration: .nanosecond)),
297+
.init(retries: 2, strategy: ConstantDelayStrategy(duration: .nanosecond)),
298+
]
299+
300+
// when
301+
let strategy = RetryPolicyStrategy.chain(entries)
302+
303+
// then
304+
XCTAssertEqual(strategy.retries, 5)
305+
}
306+
307+
func test_thatChainStrategy_returnsCustomStrategy() {
308+
// given
309+
let entries: [ChainDelayStrategy.Entry] = [
310+
.init(retries: 3, strategy: ConstantDelayStrategy(duration: .nanosecond)),
311+
.init(retries: 2, strategy: ConstantDelayStrategy(duration: .nanosecond)),
312+
]
313+
314+
// when
315+
let strategy = RetryPolicyStrategy.chain(entries)
316+
317+
// then
318+
if case .custom = strategy {
319+
XCTAssertTrue(true)
320+
} else {
321+
XCTFail("Expected .custom strategy")
322+
}
323+
}
324+
325+
func test_thatRetrySequenceCreatesASequence_whenStrategyIsChain() {
326+
// given
327+
let sequence = RetrySequence(
328+
strategy: .chain([
329+
.init(retries: 3, strategy: ConstantDelayStrategy(duration: .nanosecond)),
330+
.init(retries: 2, strategy: ConstantDelayStrategy(duration: .nanosecond)),
331+
])
332+
)
333+
334+
// when
335+
let result: [UInt64] = sequence.map { $0 }
336+
337+
// then
338+
XCTAssertEqual(result.count, 5)
339+
XCTAssertEqual(result, [1, 1, 1, 1, 1])
340+
}
341+
230342
// MARK: Helpers
231343

232344
private func toSeconds(_ nanos: UInt64) -> Double {

0 commit comments

Comments
 (0)