Browse Source

Add connection backoff (#1860)

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
George Barnett 1 year ago
parent
commit
ccf218fd0b

+ 87 - 0
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)
+    }
+  }
+}

+ 71 - 0
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<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()))
+  }
+}