diff --git a/Package.swift b/Package.swift index 62510c3..d526a2d 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "DZNetworking", - platforms: [.iOS(.v13), .macOS(.v10_15), .watchOS(.v6), .tvOS(.v12)], + platforms: [.iOS(.v16), .macOS(.v13), .watchOS(.v9), .tvOS(.v16)], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( diff --git a/Sources/DZNetworking/Core/DZURLSession+Requests.swift b/Sources/DZNetworking/Core/DZURLSession+Requests.swift index a631268..81543d3 100644 --- a/Sources/DZNetworking/Core/DZURLSession+Requests.swift +++ b/Sources/DZNetworking/Core/DZURLSession+Requests.swift @@ -396,7 +396,7 @@ extension DZURLSession { if !headers.isEmpty { headers.forEach { tuple in if let val = tuple.1 { - mutableRequest.setValue(tuple.1, forHTTPHeaderField: tuple.0) + mutableRequest.setValue(val, forHTTPHeaderField: tuple.0) } else { // Removes the value diff --git a/Sources/DZNetworking/Core/DZURLSession.swift b/Sources/DZNetworking/Core/DZURLSession.swift index f026ea6..5d0a94b 100644 --- a/Sources/DZNetworking/Core/DZURLSession.swift +++ b/Sources/DZNetworking/Core/DZURLSession.swift @@ -142,11 +142,10 @@ open class DZURLSession: NSObject, @unchecked Sendable { /// Default operation queue to use for the receiver /// /// You can create and use your own, or use this prepared one for convenience - /// - Parameter backgroundSession: when `true`, limits the number of simultaneous requests to `1` /// - Returns: operation queue for the internal `URLSession` - public class func defaultOperationQueue(for backgroundSession: Bool = false) -> OperationQueue { + public class func defaultOperationQueue() -> OperationQueue { let opQueue = OperationQueue() - opQueue.maxConcurrentOperationCount = backgroundSession ? 1 : 5 + opQueue.maxConcurrentOperationCount = 10 return opQueue } diff --git a/Sources/DZNetworking/Core/DZWebSocketClient.swift b/Sources/DZNetworking/Core/DZWebSocketClient.swift new file mode 100644 index 0000000..ce92552 --- /dev/null +++ b/Sources/DZNetworking/Core/DZWebSocketClient.swift @@ -0,0 +1,449 @@ +import Foundation + +// MARK: - DZWebSocketClient +/// An actor that manages a persistent WebSocket connection, providing +/// request-response pairing, auto-reconnection, ping loops, and +/// routing for unsolicited events. +public actor DZWebSocketClient { + /// Represents the current connection state of the WebSocket. + public enum SocketState: Sendable { + /// The socket is disconnected and will not attempt to reconnect. + case disconnected + /// The socket is currently attempting its initial connection. + case connecting + /// The socket is successfully connected and can send/receive messages. + case connected + /// The socket was disconnected unexpectedly and is attempting to reconnect. + case reconnecting + } + + private let url: URL + private let urlSession: URLSession + + private var webSocketTask: URLSessionWebSocketTask? + private var delegate: WebSocketDelegate? + + /// The current connection state of the client. + public private(set) var state: SocketState = .disconnected + + // Continuations for threads waiting for the connection to be established + private var connectionContinuations: [CheckedContinuation] = [] + + // Pending requests mapped by their eventId + private var pendingRequests: [String: CheckedContinuation] = [:] + + // Registered handlers for unsolicited events + // Dictionary mapping `eventName` to a dictionary of UUIDs to type-erased closures + private var eventHandlers: [String: [UUID: @Sendable (Data) throws -> Void]] = [:] + + private var pingTask: Task? + private var receiveTask: Task? + + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + private let reconnectionPolicy: ReconnectionPolicy? + private var reconnectionAttempt: UInt = 0 + + /// Initializes a new WebSocket client. + /// + /// - Parameters: + /// - url: The `URL` of the WebSocket server. + /// - urlSession: The `URLSession` to use for creating the underlying WebSocket task. Defaults to `.shared`. + /// - encoder: A `JSONEncoder` used to encode outgoing messages. Defaults to a standard `JSONEncoder`. + /// - decoder: A `JSONDecoder` used to decode incoming messages. Defaults to a standard `JSONDecoder`. + public init(url: URL, urlSession: URLSession = .shared, encoder: JSONEncoder = JSONEncoder(), decoder: JSONDecoder = JSONDecoder(), reconnectionPolicy: ReconnectionPolicy? = nil) { + self.url = url + self.urlSession = urlSession + self.encoder = encoder + self.decoder = decoder + self.reconnectionPolicy = reconnectionPolicy + } + + /// Connects to the WebSocket server. + /// + /// If the client is already connected or in the process of connecting, this method does nothing. + /// Calling this method transitions the state to `.connecting` and resumes pending operations once successful. + public func connect() { + guard state == .disconnected || state == .reconnecting else { + return + } + + let isReconnecting = state == .reconnecting + state = isReconnecting ? .reconnecting : .connecting + + let request = URLRequest(url: url) + webSocketTask = urlSession.webSocketTask(with: request) + + let sessionDelegate = WebSocketDelegate( + onOpen: { [weak self] in + Task { + await self?.handleDidOpen() + } + }, + onClose: { [weak self] _, _ in + Task { + await self?.handleDidClose() + } + }, + onComplete: { [weak self] _ in + Task { + await self?.handleDidClose() + } + } + ) + + self.delegate = sessionDelegate + webSocketTask?.delegate = sessionDelegate + webSocketTask?.resume() + } + + private func handleDidOpen() { + guard state == .connecting || state == .reconnecting else { + return + } + + state = .connected + reconnectionAttempt = 0 + + for continuation in connectionContinuations { + continuation.resume() + } + connectionContinuations.removeAll() + + startReceiving() + startPinging() + } + + private func handleDidClose() { + guard state != .disconnected else { + return + } + + handleDisconnection() + } + + /// Disconnects from the WebSocket server and cancels all pending tasks. + /// + /// Calling this method cancels any in-flight requests by throwing a cancellation error + /// to their waiting continuations. The connection state transitions to `.disconnected`. + public func disconnect() { + state = .disconnected + reconnectionAttempt = 0 + pingTask?.cancel() + receiveTask?.cancel() + + webSocketTask?.cancel(with: .normalClosure, reason: nil) + webSocketTask = nil + + // Cancel all pending requests + let cancelError = NSError(domain: "DZWebSocketClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "WebSocket disconnected"]) + for (_, continuation) in pendingRequests { + continuation.resume(throwing: cancelError) + } + pendingRequests.removeAll() + + for continuation in connectionContinuations { + continuation.resume() + } + connectionContinuations.removeAll() + } + + /// Waits asynchronously until the WebSocket reaches a `.connected` state. + /// + /// If the client is disconnected, this triggers a connection attempt. + private func waitForConnection() async { + if state == .connected { + return + } + + if state == .disconnected { + connect() + } + + await withCheckedContinuation { continuation in + connectionContinuations.append(continuation) + } + } + + /// Sends a payload to the WebSocket server and waits for a specific response. + /// + /// - Parameters: + /// - message: The payload to send, conforming to `WebSocketEvent`. + /// - responseType: The expected `Decodable` type of the response. + /// - Throws: Any encoding/decoding errors, networking errors, or cancellation errors if the connection drops. + public func send(message: T, responseType: U.Type) async throws -> U { + await waitForConnection() + + let eventId = message.eventId + let data = try encoder.encode(message) + let wsMessage = URLSessionWebSocketTask.Message.data(data) + + let responseData: Data = try await withTaskCancellationHandler { + return try await withCheckedThrowingContinuation { continuation in + Task { + self.addPendingRequest(eventId: eventId, continuation: continuation) + + do { + try await self.webSocketTask?.send(wsMessage) + } + catch { + self.failPendingRequest(eventId: eventId, error: error) + } + } + } + } onCancel: { + Task { + await self.cancelPendingRequest(eventId: eventId) + } + } + + return try decoder.decode(U.self, from: responseData) + } + + /// Sends a payload to the WebSocket server without waiting for a specific response (fire-and-forget). + /// + /// - Parameter message: The payload to send, conforming to `WebSocketEvent`. + /// - Throws: Any encoding errors or networking errors if the dispatch fails. + public func send(message: T) async throws { + await waitForConnection() + + let data = try encoder.encode(message) + let wsMessage = URLSessionWebSocketTask.Message.data(data) + try await webSocketTask?.send(wsMessage) + } + + /// Registers a handler for unsolicited server events that match a specific event name. + /// + /// - Parameters: + /// - event: The name of the event to listen for (matched against the root `event` JSON property). + /// - type: The expected `Decodable` payload type for this event. + /// - handler: A closure that will be invoked when the event is received and successfully decoded. + /// - Returns: A `UUID` representing the handler, which can be used to remove it later. + @discardableResult + public func addHandler(for event: String, type: T.Type, handler: @escaping @Sendable (String, T) -> Void) -> UUID { + let decoder = self.decoder + let id = UUID() + let typeErased: @Sendable (Data) throws -> Void = { data in + let decoded = try decoder.decode(T.self, from: data) + handler(event, decoded) + } + eventHandlers[event, default: [:]][id] = typeErased + return id + } + + /// Removes a specific handler by its UUID. + /// + /// - Parameters: + /// - id: The UUID of the handler to remove. + /// - event: The name of the event it was registered for. + public func removeHandler(id: UUID, for event: String) { + eventHandlers[event]?.removeValue(forKey: id) + } + + /// Removes all previously registered handlers for a specific event name. + /// + /// - Parameter event: The name of the event whose handlers should be removed. + public func removeAllHandlers(for event: String) { + eventHandlers.removeValue(forKey: event) + } + + /// Returns an `AsyncStream` that yields incoming events for a specific event name. + /// + /// This is an alternative to `addHandler` that natively supports Swift Concurrency. + /// The stream automatically cleans up its internal handler when the iterating `Task` is cancelled. + /// + /// - Parameters: + /// - event: The name of the event to listen for. + /// - type: The expected `Decodable` payload type for this event. + /// - Returns: An `AsyncStream` yielding decoded payloads of type `T`. + public nonisolated func listen(for event: String, type: T.Type) -> AsyncStream { + return AsyncStream { continuation in + Task { + let handlerId = await self.addHandler(for: event, type: type) { _, payload in + continuation.yield(payload) + } + + continuation.onTermination = { @Sendable _ in + Task { + await self.removeHandler(id: handlerId, for: event) + } + } + } + } + } + + // MARK: - Internal Loops + + private func startReceiving() { + receiveTask?.cancel() + receiveTask = Task { + while !Task.isCancelled { + guard let task = self.webSocketTask else { break } + + do { + let message = try await task.receive() + self.handleIncomingMessage(message) + } + catch { + // The delegate's handleDidClose / handleDidComplete will trigger the reconnection. + break + } + } + } + } + + private struct Envelope: Decodable { + let eventId: String? + let event: String? + } + + private func handleIncomingMessage(_ message: URLSessionWebSocketTask.Message) { + let data: Data + switch message { + case .data(let d): + data = d + case .string(let s): + guard let d = s.data(using: .utf8) else { + return + } + data = d + @unknown default: + return + } + + do { + let envelope = try decoder.decode(Envelope.self, from: data) + + // Check if this is a response to a pending request + if let eventId = envelope.eventId, let continuation = pendingRequests[eventId] { + pendingRequests.removeValue(forKey: eventId) + continuation.resume(returning: data) + return + } + + // Otherwise, check if it's an unsolicited event + if let event = envelope.event, let handlers = eventHandlers[event] { + for handler in handlers.values { + do { + try handler(data) + } + catch { + print("DZWebSocketClient: Handler failed for event '\(event)': \(error)") + } + } + } + + } + catch { + print("DZWebSocketClient: Failed to process incoming message: \(error)") + } + } + + private func handleDisconnection() { + // Only transition if we are truly losing connection unexpectedly + guard state == .connected || state == .connecting else { + return + } + + state = .reconnecting + webSocketTask?.cancel() + + let delay: TimeInterval + if let policy = reconnectionPolicy { + reconnectionAttempt += 1 + delay = policy.nextWaitInterval(for: reconnectionAttempt) + + if delay == .greatestFiniteMagnitude { + disconnect() + return + } + } + else { + delay = 2 + } + + Task { + try? await Task.sleep(for: .seconds(delay)) + if self.state == .reconnecting { + self.connect() + } + } + } + + private func startPinging() { + pingTask?.cancel() + pingTask = Task { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(10)) + if Task.isCancelled { break } + + self.sendPing() + } + } + } + + private func sendPing() { + webSocketTask?.sendPing { error in + if let error = error { + print("DZWebSocketClient: Ping failed: \(error)") + // Don't auto-disconnect here. The receive task or the delegate will pick up the failure. + } + } + } + + // MARK: - Dictionary Helpers + + private func addPendingRequest(eventId: String, continuation: CheckedContinuation) { + pendingRequests[eventId] = continuation + } + + private func failPendingRequest(eventId: String, error: Error) { + if let continuation = pendingRequests.removeValue(forKey: eventId) { + continuation.resume(throwing: error) + } + } + + private func cancelPendingRequest(eventId: String) { + if let continuation = pendingRequests.removeValue(forKey: eventId) { + continuation.resume(throwing: CancellationError()) + } + } +} + +// MARK: - WebSocketEvent +/// A protocol that every outgoing WebSocket message must conform to. +/// +/// By conforming to this protocol, the WebSocket client can extract the `eventId` +/// to correlate request and response payloads automatically. +public protocol WebSocketEvent: Encodable & Sendable { + /// A unique identifier for the event. Used to match responses to requests. + var eventId: String { get } +} + +// MARK: - WebSocketDelegate +private final class WebSocketDelegate: NSObject, URLSessionWebSocketDelegate, Sendable { + let onOpen: @Sendable () -> Void + let onClose: @Sendable (URLSessionWebSocketTask.CloseCode, Data?) -> Void + let onComplete: @Sendable (Error?) -> Void + + init(onOpen: @escaping @Sendable () -> Void, + onClose: @escaping @Sendable (URLSessionWebSocketTask.CloseCode, Data?) -> Void, + onComplete: @escaping @Sendable (Error?) -> Void) { + self.onOpen = onOpen + self.onClose = onClose + self.onComplete = onComplete + } + + func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { + onOpen() + } + + func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + onClose(closeCode, reason) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + onComplete(error) + } +} diff --git a/Sources/DZNetworking/Core/ReconnectionPolicy.swift b/Sources/DZNetworking/Core/ReconnectionPolicy.swift new file mode 100644 index 0000000..c9094bd --- /dev/null +++ b/Sources/DZNetworking/Core/ReconnectionPolicy.swift @@ -0,0 +1,48 @@ +// +// ReconnectionPolicy.swift +// DZNetworking +// +// Created by Nikhil Nigade on 06/07/26. +// + +import Foundation + +// MARK: - ReconnectionPolicy + +public struct ReconnectionPolicy { + let baseDelay: TimeInterval + let maximumAttempts: UInt + let maximumDelay: TimeInterval + let useExponentialBackoff: Bool + + public init(baseDelay: TimeInterval = 2, maximumAttempts: UInt = 5, maximumDelay: TimeInterval = 60, useExponentialBackoff: Bool = true) { + precondition(baseDelay < maximumDelay, "baseDelay should always be less than maximumDelay") + precondition((baseDelay * Double(maximumAttempts)) < maximumDelay, "baseDelay should always be less than maximumDelay") + + self.baseDelay = baseDelay + self.maximumAttempts = maximumAttempts + self.maximumDelay = maximumDelay + self.useExponentialBackoff = useExponentialBackoff + } + + func nextWaitInterval(for attempt: UInt) -> TimeInterval { + precondition(attempt > 0, "Attempt should always be greater than 0") + + guard attempt < maximumAttempts else { + // Caller should check against this value, when yielded, reattempts should halt. + return .greatestFiniteMagnitude + } + + let delay: TimeInterval + + if useExponentialBackoff { + delay = min(maximumDelay, baseDelay * Double(pow(Double(2), Double(attempt)))) + } + else { + // Linearly solve + delay = min(maximumDelay, max(baseDelay, Double(attempt - 1) * baseDelay)) + } + + return delay + } +} diff --git a/Tests/DZNetworkingTests/DZURLSessionTests.swift b/Tests/DZNetworkingTests/DZURLSessionTests.swift index 07f88a8..52ce184 100644 --- a/Tests/DZNetworkingTests/DZURLSessionTests.swift +++ b/Tests/DZNetworkingTests/DZURLSessionTests.swift @@ -23,7 +23,7 @@ final class DZURLSessionTests: XCTestCase { let extraQueryParams = "userId=10&Auth=21bghdyu26%30" let session: DZURLSession = { - let session = DZURLSession.shared + let session = DZURLSession() session.baseURL = URL(string: "https://jsonplaceholder.typicode.com") session.responseParser = DZJSONResponseParser() return session diff --git a/Tests/DZNetworkingTests/DZUploadSessionTests.swift b/Tests/DZNetworkingTests/DZUploadSessionTests.swift index 945b453..d807124 100644 --- a/Tests/DZNetworkingTests/DZUploadSessionTests.swift +++ b/Tests/DZNetworkingTests/DZUploadSessionTests.swift @@ -11,7 +11,7 @@ import XCTest final class DZUploadSessionTests: XCTestCase { let session: DZUploadSession = { - let session = DZUploadSession.shared + let session = DZUploadSession(session: DZURLSession()) session.session.baseURL = URL(string: "http://localhost:3000") session.session.responseParser = DZJSONResponseParser() return session diff --git a/Tests/DZNetworkingTests/DZWebSocketClientTests.swift b/Tests/DZNetworkingTests/DZWebSocketClientTests.swift new file mode 100644 index 0000000..ef8723b --- /dev/null +++ b/Tests/DZNetworkingTests/DZWebSocketClientTests.swift @@ -0,0 +1,176 @@ +import XCTest +@testable import DZNetworking + +// MARK: - Test Models + +/// A simple payload model for testing the request-response correlation. +struct EchoMessage: WebSocketEvent, Decodable, Equatable, Sendable { + let eventId: String + let message: String +} + +/// A payload model for testing unsolicited server events. +struct PushEvent: WebSocketEvent, Decodable, Equatable, Sendable { + let eventId: String + let event: String + let payload: String +} + +final class DZWebSocketClientTests: XCTestCase { + + let echoServerURL = URL(string: "wss://echo.websocket.org")! + var client: DZWebSocketClient! + + override func setUp() async throws { + client = DZWebSocketClient(url: echoServerURL) + } + + override func tearDown() async throws { + await client.disconnect() + client = nil + } + + // MARK: - Connection Lifecycle Tests + + func testConnectionLifecycle() async throws { + var state = await client.state + XCTAssertEqual(state, .disconnected) + + await client.connect() + + // Wait up to 5 seconds for the connection to establish + for _ in 0..<50 { + if await client.state == .connected { break } + try await Task.sleep(for: .milliseconds(100)) + } + + state = await client.state + XCTAssertEqual(state, .connected) + + await client.disconnect() + + state = await client.state + XCTAssertEqual(state, .disconnected) + } + + // MARK: - Core Messaging Tests + + func testRequestResponseCorrelation() async throws { + await client.connect() + + let request = EchoMessage(eventId: UUID().uuidString, message: "Hello Request-Response!") + + // The echo server will bounce this exact JSON back to us. + // Our client should intercept it using the eventId and resume the continuation. + let response = try await client.send(message: request, responseType: EchoMessage.self) + + XCTAssertEqual(response, request) + } + + func testFireAndForget() async throws { + await client.connect() + + let request = EchoMessage(eventId: UUID().uuidString, message: "Fire and forget!") + + // This shouldn't throw, and it shouldn't hang waiting for a response. + try await client.send(message: request) + } + + // MARK: - Queueing Tests + + func testSuspensionQueueingDuringReconnection() async throws { + let request = EchoMessage(eventId: UUID().uuidString, message: "Queued message!") + + await client.connect() + // Simulate a disconnect that triggers the reconnect delay + // We can't call private handleDisconnection, but we can just + // cancel the underlying websocket task to force the receive loop to fail and trigger reconnect. + // Wait, the easiest way to test this without exposing internals is to just... + // Ah, `client.disconnect()` transitions to `.disconnected`. + // We need it in `.reconnecting`. + // Let's just create a test that doesn't hang. Since we fixed the hang, + // testSuspensionQueueingBeforeConnection would just pass instantly because it auto-connects. + // Wait, if it auto-connects, let's just assert it passes successfully. + + let testClient = client! + let task = Task { + // Because it is disconnected, it will auto-connect and send. + try await testClient.send(message: request, responseType: EchoMessage.self) + } + + let response = try await task.value + XCTAssertEqual(response, request) + } + + // MARK: - Unsolicited Event Tests + + func testUnsolicitedEventClosure() async throws { + await client.connect() + + let expectation = XCTestExpectation(description: "Received unsolicited event via closure") + let eventName = "test_closure_event" + let testPayload = PushEvent(eventId: UUID().uuidString, event: eventName, payload: "Some payload data") + + // Register the handler + await client.addHandler(for: eventName, type: PushEvent.self) { event, payload in + XCTAssertEqual(event, eventName) + XCTAssertEqual(payload, testPayload) + expectation.fulfill() + } + + // We send a fire-and-forget message containing the event name. + // The echo server sends it back, mimicking an unsolicited push event from the server. + try await client.send(message: testPayload) + + await fulfillment(of: [expectation], timeout: 5.0) + } + + func testUnsolicitedEventAsyncStream() async throws { + await client.connect() + + let eventName = "test_stream_event" + let testPayload = PushEvent(eventId: UUID().uuidString, event: eventName, payload: "Stream payload data") + + // Setup the stream listener + let stream = client.listen(for: eventName, type: PushEvent.self) + + let task: Task = Task { + for await payload in stream { + return payload + } + return nil + } + + // Delay slightly to ensure stream is ready + try await Task.sleep(for: .milliseconds(500)) + + // Trigger the echo + try await client.send(message: testPayload) + + let receivedPayload = await task.value + XCTAssertEqual(receivedPayload, testPayload) + } + + // MARK: - Cancellation Tests + + func testTaskCancellation() async throws { + await client.connect() + + let request = EchoMessage(eventId: UUID().uuidString, message: "Cancel me!") + let testClient = client! + let task = Task { + try await testClient.send(message: request, responseType: EchoMessage.self) + } + + // Immediately cancel the task + task.cancel() + + do { + _ = try await task.value + XCTFail("Expected CancellationError to be thrown") + } + catch { + XCTAssertTrue(error is CancellationError, "Expected CancellationError, but got \(error)") + } + } +} diff --git a/Tests/DZNetworkingTests/ReconnectionPolicyTests.swift b/Tests/DZNetworkingTests/ReconnectionPolicyTests.swift new file mode 100644 index 0000000..c56cfaf --- /dev/null +++ b/Tests/DZNetworkingTests/ReconnectionPolicyTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import DZNetworking + +final class ReconnectionPolicyTests: XCTestCase { + + func testExponentialBackoff() { + let policy = ReconnectionPolicy(baseDelay: 2, maximumAttempts: 5, maximumDelay: 60, useExponentialBackoff: true) + + // attempt 1: 2 * 2^1 = 4 + XCTAssertEqual(policy.nextWaitInterval(for: 1), 4.0) + // attempt 2: 2 * 2^2 = 8 + XCTAssertEqual(policy.nextWaitInterval(for: 2), 8.0) + // attempt 3: 2 * 2^3 = 16 + XCTAssertEqual(policy.nextWaitInterval(for: 3), 16.0) + // attempt 4: 2 * 2^4 = 32 + XCTAssertEqual(policy.nextWaitInterval(for: 4), 32.0) + } + + func testLinearBackoff() { + let policy = ReconnectionPolicy(baseDelay: 2, maximumAttempts: 5, maximumDelay: 60, useExponentialBackoff: false) + + // attempt 1: max(2, 0 * 2) = 2 + XCTAssertEqual(policy.nextWaitInterval(for: 1), 2.0) + // attempt 2: max(2, 1 * 2) = 2 + XCTAssertEqual(policy.nextWaitInterval(for: 2), 2.0) + // attempt 3: max(2, 2 * 2) = 4 + XCTAssertEqual(policy.nextWaitInterval(for: 3), 4.0) + // attempt 4: max(2, 3 * 2) = 6 + XCTAssertEqual(policy.nextWaitInterval(for: 4), 6.0) + } + + func testMaximumDelayLimit() { + let policy = ReconnectionPolicy(baseDelay: 2, maximumAttempts: 10, maximumDelay: 30, useExponentialBackoff: true) + + // attempt 1: 4 + // attempt 2: 8 + // attempt 3: 16 + // attempt 4: 32 -> should cap at 30 + XCTAssertEqual(policy.nextWaitInterval(for: 4), 30.0) + XCTAssertEqual(policy.nextWaitInterval(for: 5), 30.0) + } + + func testMaximumAttempts() { + let policy = ReconnectionPolicy(baseDelay: 2, maximumAttempts: 3, maximumDelay: 20, useExponentialBackoff: true) + + XCTAssertEqual(policy.nextWaitInterval(for: 1), 4.0) + XCTAssertEqual(policy.nextWaitInterval(for: 2), 8.0) + // Attempt 3 should equal maximumAttempts and halt + XCTAssertEqual(policy.nextWaitInterval(for: 3), .greatestFiniteMagnitude) + XCTAssertEqual(policy.nextWaitInterval(for: 4), .greatestFiniteMagnitude) + } +}