Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions Sources/GRPCHTTP2Core/Client/Connection/ConnectionBackoff.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright 2024, gRPC Authors All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
struct ConnectionBackoff {
var initial: Duration
var max: Duration
var multiplier: Double
var jitter: Double

init(initial: Duration, max: Duration, multiplier: Double, jitter: Double) {
self.initial = initial
self.max = max
self.multiplier = multiplier
self.jitter = jitter
}

func makeIterator() -> Iterator {
return Iterator(self)
}

// Deliberately not conforming to `IteratorProtocol` as `next()` never returns `nil` which
// isn't expressible via `IteratorProtocol`.
struct Iterator {
private var isInitial: Bool
private var currentBackoffSeconds: Double

private let jitter: Double
private let multiplier: Double
private let maxBackoffSeconds: Double

init(_ backoff: ConnectionBackoff) {
self.isInitial = true
self.currentBackoffSeconds = Self.seconds(from: backoff.initial)
self.jitter = backoff.jitter
self.multiplier = backoff.multiplier
self.maxBackoffSeconds = Self.seconds(from: backoff.max)
}

private static func seconds(from duration: Duration) -> Double {
var seconds = Double(duration.components.seconds)
seconds += Double(duration.components.attoseconds) / 1e18
return seconds
}

private static func duration(from seconds: Double) -> Duration {
let nanoseconds = seconds * 1e9
let wholeNanos = Int64(nanoseconds)
return .nanoseconds(wholeNanos)
}

mutating func next() -> Duration {
// The initial backoff doesn't get jittered.
if self.isInitial {
self.isInitial = false
return Self.duration(from: self.currentBackoffSeconds)
}

// Scale up the last backoff.
self.currentBackoffSeconds *= self.multiplier

// Limit it to the max backoff.
if self.currentBackoffSeconds > self.maxBackoffSeconds {
self.currentBackoffSeconds = self.maxBackoffSeconds
}

let backoff = self.currentBackoffSeconds
let jitter = Double.random(in: -(self.jitter * backoff) ... self.jitter * backoff)
let jitteredBackoff = backoff + jitter

return Self.duration(from: jitteredBackoff)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright 2024, gRPC Authors All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import XCTest

@testable import GRPCHTTP2Core

@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
final class ConnectionBackoffTests: XCTestCase {
func testUnjitteredBackoff() {
let backoff = ConnectionBackoff(
initial: .seconds(10),
max: .seconds(30),
multiplier: 1.5,
jitter: 0.0
)

var iterator = backoff.makeIterator()
XCTAssertEqual(iterator.next(), .seconds(10))
// 10 * 1.5 = 15 seconds
XCTAssertEqual(iterator.next(), .seconds(15))
// 15 * 1.5 = 22.5 seconds
XCTAssertEqual(iterator.next(), .seconds(22.5))
// 22.5 * 1.5 = 33.75 seconds, clamped to 30 seconds, all future values will be the same.
XCTAssertEqual(iterator.next(), .seconds(30))
XCTAssertEqual(iterator.next(), .seconds(30))
XCTAssertEqual(iterator.next(), .seconds(30))
}

func testJitteredBackoff() {
let backoff = ConnectionBackoff(
initial: .seconds(10),
max: .seconds(30),
multiplier: 1.5,
jitter: 0.1
)

var iterator = backoff.makeIterator()

// Initial isn't jittered.
XCTAssertEqual(iterator.next(), .seconds(10))

// Next value should be 10 * 1.5 = 15 seconds ± 1.5 seconds
var expected: ClosedRange<Duration> = .seconds(13.5) ... .seconds(16.5)
XCTAssert(expected.contains(iterator.next()))

// Next value should be 15 * 1.5 = 22.5 seconds ± 2.25 seconds
expected = .seconds(20.25) ... .seconds(24.75)
XCTAssert(expected.contains(iterator.next()))

// Next value should be 22.5 * 1.5 = 33.75 seconds, clamped to 30 seconds ± 3 seconds.
// All future values will be in the same range.
expected = .seconds(27) ... .seconds(33)
XCTAssert(expected.contains(iterator.next()))
XCTAssert(expected.contains(iterator.next()))
XCTAssert(expected.contains(iterator.next()))
}
}