From 5f716a558aacf1e59ee9bb99316330b51b86362f Mon Sep 17 00:00:00 2001 From: George Barnett Date: Tue, 16 Apr 2024 11:29:05 +0100 Subject: [PATCH] Add connection backoff Motivation: Connection attempts should be made with a backoff period between them. Modifications: - Add a connection backoff struct which can make an iterator to produces duration to backoff by Result: Can do backoff --- .../Client/Connection/ConnectionBackoff.swift | 87 +++++++++++++++++++ .../Connection/ConnectionBackoffTests.swift | 71 +++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 Sources/GRPCHTTP2Core/Client/Connection/ConnectionBackoff.swift create mode 100644 Tests/GRPCHTTP2CoreTests/Client/Connection/ConnectionBackoffTests.swift diff --git a/Sources/GRPCHTTP2Core/Client/Connection/ConnectionBackoff.swift b/Sources/GRPCHTTP2Core/Client/Connection/ConnectionBackoff.swift new file mode 100644 index 000000000..49feb2282 --- /dev/null +++ b/Sources/GRPCHTTP2Core/Client/Connection/ConnectionBackoff.swift @@ -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) + } + } +} diff --git a/Tests/GRPCHTTP2CoreTests/Client/Connection/ConnectionBackoffTests.swift b/Tests/GRPCHTTP2CoreTests/Client/Connection/ConnectionBackoffTests.swift new file mode 100644 index 000000000..3898513ca --- /dev/null +++ b/Tests/GRPCHTTP2CoreTests/Client/Connection/ConnectionBackoffTests.swift @@ -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 = .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())) + } +}