Skip to content

Commit 60e3276

Browse files
Extract BackoffCounter to independent module
- Move ReconnectBackoffCounter to Sources/BackoffCounter/BackoffCounter.swift - Move BackoffCounterTimer to Sources/BackoffCounter/BackoffCounterTimer.swift - Move tests to Sources/BackoffCounter/Tests/ - Rename ReconnectBackoffCounter -> BackoffCounter - Replace AtomicInt/Atomic<Bool> with NSLock for module independence - Replace DispatchQueue.general with DispatchQueue.global() - Add conditional import for Logging module (#if !COCOAPODS) - Add README with usage documentation Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8afe18d commit 60e3276

19 files changed

+245
-137
lines changed
Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// ReconnectBackoffCounter.swift
2+
// BackoffCounter.swift
33
// Split
44
//
55
// Created by Javier L. Avrudsky on 13/08/2020.
@@ -8,29 +8,33 @@
88

99
import Foundation
1010

11-
protocol ReconnectBackoffCounter {
11+
public protocol BackoffCounter {
1212
func getNextRetryTime() -> Double
1313
func resetCounter()
1414
}
1515

16-
class DefaultReconnectBackoffCounter: ReconnectBackoffCounter, @unchecked Sendable {
16+
public class DefaultBackoffCounter: BackoffCounter, @unchecked Sendable {
1717
private var maxTimeLimitInSecs: Double = 1800.0 // 30 minutes (30 * 60)
1818
private static let kRetryExponentialBase = 2
1919
private let backoffBase: Int
20-
private var attemptCount: AtomicInt
20+
private var attemptCount: Int = 0
21+
private let lock = NSLock()
2122

22-
init(backoffBase: Int, maxTimeLimit: Int? = nil) {
23+
public init(backoffBase: Int, maxTimeLimit: Int? = nil) {
2324
self.backoffBase = backoffBase
24-
self.attemptCount = AtomicInt(0)
2525
if let max = maxTimeLimit {
2626
maxTimeLimitInSecs = Double(max)
2727
}
2828
}
2929

30-
func getNextRetryTime() -> Double {
30+
public func getNextRetryTime() -> Double {
31+
lock.lock()
32+
let currentAttempt = attemptCount
33+
attemptCount += 1
34+
lock.unlock()
3135

3236
let base = Decimal(backoffBase * Self.kRetryExponentialBase)
33-
let decimalResult = pow(base, attemptCount.getAndAdd(1))
37+
let decimalResult = pow(base, currentAttempt)
3438

3539
var retryTime = maxTimeLimitInSecs
3640
if !decimalResult.isNaN, decimalResult < Decimal(maxTimeLimitInSecs) {
@@ -39,7 +43,9 @@ class DefaultReconnectBackoffCounter: ReconnectBackoffCounter, @unchecked Sendab
3943
return retryTime
4044
}
4145

42-
func resetCounter() {
43-
attemptCount .mutate { $0 = 0 }
46+
public func resetCounter() {
47+
lock.lock()
48+
attemptCount = 0
49+
lock.unlock()
4450
}
4551
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//
2+
// BackoffCounterTimer.swift
3+
// Split
4+
//
5+
// Created by Javier L. Avrudsky on 20/10/2020.
6+
// Copyright © 2020 Split. All rights reserved.
7+
//
8+
9+
import Foundation
10+
#if !COCOAPODS
11+
import Logging
12+
#endif
13+
14+
public protocol BackoffCounterTimer {
15+
func schedule(handler: @escaping @Sendable () -> Void)
16+
func cancel()
17+
}
18+
19+
public class DefaultBackoffCounterTimer: BackoffCounterTimer, @unchecked Sendable {
20+
private let backoffCounter: BackoffCounter
21+
private let queue = DispatchQueue(label: "split-backoff-timer")
22+
private let timersQueue = DispatchQueue.global(qos: .default)
23+
private var workItem: DispatchWorkItem?
24+
private var isScheduled: Bool = false
25+
private let scheduleLock = NSLock()
26+
27+
public init(backoffCounter: BackoffCounter) {
28+
self.backoffCounter = backoffCounter
29+
}
30+
31+
public func schedule(handler: @escaping @Sendable () -> Void) {
32+
queue.async {
33+
self.schedule(handler)
34+
}
35+
}
36+
37+
public func cancel() {
38+
queue.async {
39+
self.workItem?.cancel()
40+
self.workItem = nil
41+
self.backoffCounter.resetCounter()
42+
}
43+
}
44+
45+
private func schedule(_ handler: @escaping () -> Void) {
46+
scheduleLock.lock()
47+
let wasScheduled = isScheduled
48+
if workItem != nil, wasScheduled {
49+
scheduleLock.unlock()
50+
return
51+
}
52+
isScheduled = true
53+
scheduleLock.unlock()
54+
55+
let workItem = DispatchWorkItem(block: {
56+
self.scheduleLock.lock()
57+
self.isScheduled = false
58+
self.scheduleLock.unlock()
59+
handler()
60+
})
61+
let delayInSeconds = backoffCounter.getNextRetryTime()
62+
Logger.d("Retrying reconnection in \(delayInSeconds) seconds")
63+
timersQueue.asyncAfter(deadline: DispatchTime.now() + Double(delayInSeconds), execute: workItem)
64+
self.workItem = workItem
65+
}
66+
}

Sources/BackoffCounter/README.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# BackoffCounter
2+
3+
A thread-safe exponential backoff implementation for retry logic.
4+
5+
## Overview
6+
7+
This module provides two main components:
8+
9+
- **BackoffCounter**: Calculates exponential backoff times for retry operations
10+
- **BackoffCounterTimer**: Schedules operations with automatic backoff delays
11+
12+
## Usage
13+
14+
### Basic BackoffCounter
15+
16+
```swift
17+
// Create a counter with base 1 (delays: 1s, 2s, 4s, 8s, 16s... up to 30 min)
18+
let counter = DefaultBackoffCounter(backoffBase: 1)
19+
20+
// Get next retry time (exponentially increasing)
21+
let delay1 = counter.getNextRetryTime() // 1.0
22+
let delay2 = counter.getNextRetryTime() // 2.0
23+
let delay3 = counter.getNextRetryTime() // 4.0
24+
25+
// Reset after successful operation
26+
counter.resetCounter()
27+
```
28+
29+
### Custom Configuration
30+
31+
```swift
32+
// Higher base = faster growth (delays: 1s, 4s, 16s, 64s...)
33+
let aggressiveCounter = DefaultBackoffCounter(backoffBase: 2)
34+
35+
// Custom max time limit (default is 1800 seconds / 30 minutes)
36+
let limitedCounter = DefaultBackoffCounter(backoffBase: 1, maxTimeLimit: 60)
37+
```
38+
39+
### BackoffCounterTimer
40+
41+
```swift
42+
let counter = DefaultBackoffCounter(backoffBase: 1)
43+
let timer = DefaultBackoffCounterTimer(backoffCounter: counter)
44+
45+
// Schedule a retry operation
46+
timer.schedule {
47+
// This will be called after the backoff delay
48+
performRetryOperation()
49+
}
50+
51+
// Cancel pending retry and reset counter
52+
timer.cancel()
53+
```
54+
55+
## Backoff Formula
56+
57+
The retry time is calculated as:
58+
59+
```
60+
retryTime = (backoffBase * 2) ^ attemptCount
61+
```
62+
63+
Where `attemptCount` starts at 0 and increments with each call to `getNextRetryTime()`.
64+
65+
### Example Sequences
66+
67+
| Base | Attempt 0 | Attempt 1 | Attempt 2 | Attempt 3 | Max (default) |
68+
|------|-----------|-----------|-----------|-----------|---------------|
69+
| 1 | 1s | 2s | 4s | 8s | 1800s |
70+
| 2 | 1s | 4s | 16s | 64s | 1800s |
71+
| 3 | 1s | 6s | 36s | 216s | 1800s |
72+
73+
## Thread Safety
74+
75+
Both `DefaultBackoffCounter` and `DefaultBackoffCounterTimer` are thread-safe and conform to `Sendable`.

SplitTests/Streaming/ReconnectBackoffCounterTest.swift renamed to Sources/BackoffCounter/Tests/BackoffCounterTest.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// ReconnectBackoffCounterTest.swift
2+
// BackoffCounterTest.swift
33
// SplitTests
44
//
55
// Created by Javier L. Avrudsky on 13/08/2020.
@@ -11,7 +11,7 @@ import Foundation
1111
import XCTest
1212
@testable import Split
1313

14-
class ReconnectBackoffCounterTest: XCTestCase {
14+
class BackoffCounterTest: XCTestCase {
1515
override func setUp() {
1616
}
1717

@@ -36,7 +36,7 @@ class ReconnectBackoffCounterTest: XCTestCase {
3636
}
3737

3838
private func testWithBase(base: Int, results: [Double]) {
39-
let counter = DefaultReconnectBackoffCounter(backoffBase: base);
39+
let counter = DefaultBackoffCounter(backoffBase: base);
4040
let v1 = counter.getNextRetryTime()
4141
let v2 = counter.getNextRetryTime()
4242
let v3 = counter.getNextRetryTime()

0 commit comments

Comments
 (0)