diff --git a/Sources/Typhoon/Classes/Extensions/URLSession+RetryPolicy.swift b/Sources/Typhoon/Classes/Extensions/URLSession+RetryPolicy.swift new file mode 100644 index 0000000..8ac42e2 --- /dev/null +++ b/Sources/Typhoon/Classes/Extensions/URLSession+RetryPolicy.swift @@ -0,0 +1,95 @@ +// +// Typhoon +// Copyright © 2026 Space Code. All rights reserved. +// + +#if canImport(Darwin) + import Foundation + + public extension URLSession { + /// Performs a data task with retry policy applied. + /// + /// - Parameters: + /// - request: The URL request to perform. + /// - strategy: The retry strategy to apply. + /// - onFailure: An optional closure called on each failure. Return `false` to stop retrying early. + /// - Returns: A tuple of `(Data, URLResponse)`. + func data( + for request: URLRequest, + retryPolicy strategy: RetryPolicyStrategy, + onFailure: (@Sendable (Error) async -> Bool)? = nil + ) async throws -> (Data, URLResponse) { + try await RetryPolicyService(strategy: strategy).retry( + strategy: nil, + onFailure: onFailure + ) { + try await self.data(for: request) + } + } + + /// Performs a data task for a URL with retry policy applied. + /// + /// - Parameters: + /// - url: The URL to fetch. + /// - strategy: The retry strategy to apply. + /// - onFailure: An optional closure called on each failure. Return `false` to stop retrying early. + /// - Returns: A tuple of `(Data, URLResponse)`. + func data( + from url: URL, + retryPolicy strategy: RetryPolicyStrategy, + onFailure: (@Sendable (Error) async -> Bool)? = nil + ) async throws -> (Data, URLResponse) { + try await RetryPolicyService(strategy: strategy).retry( + strategy: nil, + onFailure: onFailure + ) { + try await self.data(from: url) + } + } + + /// Uploads data for a request with retry policy applied. + /// + /// - Parameters: + /// - request: The URL request to use for the upload. + /// - bodyData: The data to upload. + /// - strategy: The retry strategy to apply. + /// - onFailure: An optional closure called on each failure. Return `false` to stop retrying early. + /// - Returns: A tuple of `(Data, URLResponse)`. + func upload( + for request: URLRequest, + from bodyData: Data, + retryPolicy strategy: RetryPolicyStrategy, + onFailure: (@Sendable (Error) async -> Bool)? = nil + ) async throws -> (Data, URLResponse) { + try await RetryPolicyService(strategy: strategy).retry( + strategy: nil, + onFailure: onFailure + ) { + try await self.upload(for: request, from: bodyData) + } + } + + /// Downloads a file for a request with retry policy applied. + /// + /// - Parameters: + /// - request: The URL request to use for the download. + /// - strategy: The retry strategy to apply. + /// - delegate: A delegate that receives life cycle and authentication challenge callbacks as the transfer progresses. + /// - onFailure: An optional closure called on each failure. Return `false` to stop retrying early. + /// - Returns: A tuple of `(URL, URLResponse)` where `URL` is the temporary file location. + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + func download( + for request: URLRequest, + retryPolicy strategy: RetryPolicyStrategy, + delegate: (any URLSessionTaskDelegate)? = nil, + onFailure: (@Sendable (Error) async -> Bool)? = nil + ) async throws -> (URL, URLResponse) { + try await RetryPolicyService(strategy: strategy).retry( + strategy: nil, + onFailure: onFailure + ) { + try await self.download(for: request, delegate: delegate) + } + } + } +#endif diff --git a/Tests/TyphoonTests/Helpers/Counter.swift b/Tests/TyphoonTests/Helpers/Counter.swift new file mode 100644 index 0000000..77b2603 --- /dev/null +++ b/Tests/TyphoonTests/Helpers/Counter.swift @@ -0,0 +1,30 @@ +// +// Typhoon +// Copyright © 2026 Space Code. All rights reserved. +// + +import Foundation + +final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var _value: UInt = 0 + + var value: UInt { + lock.withLock { _value } + } + + @discardableResult + func increment() -> UInt { + lock.withLock { + _value += 1 + return _value + } + } + + @discardableResult + func getValue() -> UInt { + lock.withLock { + _value + } + } +} diff --git a/Tests/TyphoonTests/Mocks/MockURLProtocol.swift b/Tests/TyphoonTests/Mocks/MockURLProtocol.swift new file mode 100644 index 0000000..fd62d8b --- /dev/null +++ b/Tests/TyphoonTests/Mocks/MockURLProtocol.swift @@ -0,0 +1,36 @@ +// +// Typhoon +// Copyright © 2026 Space Code. All rights reserved. +// + +#if canImport(Darwin) + import Foundation + + // MARK: - MockURLProtocol + + final class MockURLProtocol: URLProtocol, @unchecked Sendable { + override class func canInit(with _: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + let client = client + Task { + do { + let (response, data) = try await MockURLProtocolHandler.shared.callHandler() + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + } + + override func stopLoading() {} + } +#endif diff --git a/Tests/TyphoonTests/Mocks/MockURLProtocolHandler.swift b/Tests/TyphoonTests/Mocks/MockURLProtocolHandler.swift new file mode 100644 index 0000000..35333fb --- /dev/null +++ b/Tests/TyphoonTests/Mocks/MockURLProtocolHandler.swift @@ -0,0 +1,27 @@ +// +// Typhoon +// Copyright © 2026 Space Code. All rights reserved. +// + +#if canImport(Darwin) + import Foundation + + // MARK: - MockURLProtocolHandler + + actor MockURLProtocolHandler { + typealias Handler = @Sendable () throws -> (HTTPURLResponse, Data) + + static let shared = MockURLProtocolHandler() + + private var handler: Handler? + + func set(_ handler: Handler?) { + self.handler = handler + } + + func callHandler() throws -> (HTTPURLResponse, Data) { + guard let handler else { throw URLError(.unknown) } + return try handler() + } + } +#endif diff --git a/Tests/TyphoonTests/UnitTests/RetryPolicyServiceRetryWithResultTests.swift b/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceRetryWithResultTests.swift similarity index 86% rename from Tests/TyphoonTests/UnitTests/RetryPolicyServiceRetryWithResultTests.swift rename to Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceRetryWithResultTests.swift index f267ad0..3db949a 100644 --- a/Tests/TyphoonTests/UnitTests/RetryPolicyServiceRetryWithResultTests.swift +++ b/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceRetryWithResultTests.swift @@ -16,25 +16,18 @@ final class RetryPolicyServiceRetryWithResultTests: XCTestCase { case fatal } - // MARK: - Counter - - private actor Counter { - private(set) var count: Int = 0 - - func increment() { - count += 1 - } - } - // MARK: Tests func test_retryWithResult_succeedsOnFirstAttempt() async throws { + // given let sut = RetryPolicyService(strategy: .constant(retry: 3, dispatchDuration: .milliseconds(10))) + // when let result = try await sut.retryWithResult { 42 } + // then XCTAssertEqual(result.value, 42) XCTAssertEqual(result.attempts, 1) XCTAssertTrue(result.errors.isEmpty) @@ -42,18 +35,21 @@ final class RetryPolicyServiceRetryWithResultTests: XCTestCase { } func test_retryWithResult_succeedsAfterSeveralFailures() async throws { + // given let sut = RetryPolicyService(strategy: .constant(retry: 5, dispatchDuration: .milliseconds(10))) let counter = Counter() + // when let result = try await sut.retryWithResult { - await counter.increment() - if await counter.count < 3 { + counter.increment() + if counter.value < 3 { throw TestError.transient } return "ok" } + // then XCTAssertEqual(result.value, "ok") XCTAssertEqual(result.attempts, 3) XCTAssertEqual(result.errors.count, 2) @@ -61,8 +57,10 @@ final class RetryPolicyServiceRetryWithResultTests: XCTestCase { } func test_retryWithResult_throwsRetryLimitExceeded_whenAllAttemptsFail() async throws { + // given let sut = RetryPolicyService(strategy: .constant(retry: 3, dispatchDuration: .milliseconds(10))) + // when do { _ = try await sut.retryWithResult { throw TestError.transient @@ -72,67 +70,74 @@ final class RetryPolicyServiceRetryWithResultTests: XCTestCase { } func test_retryWithResult_stopsRetrying_whenOnFailureReturnsFalse() async throws { + // given let sut = RetryPolicyService(strategy: .constant(retry: 5, dispatchDuration: .milliseconds(10))) let counter = Counter() + // when do { _ = try await sut.retryWithResult( onFailure: { _ in false } ) { - await counter.increment() + counter.increment() throw TestError.fatal } XCTFail("Expected error to be rethrown") } catch { XCTAssertEqual(error as? TestError, .fatal) - let count = await counter.count + let count = counter.value XCTAssertEqual(count, 1) } } func test_retryWithResult_stopsRetrying_onSpecificError() async throws { + // given let sut = RetryPolicyService(strategy: .constant(retry: 5, dispatchDuration: .milliseconds(10))) let counter = Counter() + // when do { _ = try await sut.retryWithResult( onFailure: { error in (error as? TestError) == .transient } ) { - await counter.increment() - let current = await counter.count + counter.increment() + let current = counter.value throw current == 1 ? TestError.transient : TestError.fatal } XCTFail("Expected error to be rethrown") } catch { XCTAssertEqual(error as? TestError, .fatal) - let count = await counter.count + let count = counter.value XCTAssertEqual(count, 2) } } func test_retryWithResult_onFailureReceivesAllErrors() async throws { + // given let sut = RetryPolicyService(strategy: .constant(retry: 4, dispatchDuration: .milliseconds(10))) let counter = Counter() let receivedErrors = ErrorCollector() + // when let result = try await sut.retryWithResult( onFailure: { error in await receivedErrors.append(error) return true } ) { - await counter.increment() - if await counter.count < 4 { + counter.increment() + if counter.value < 4 { throw TestError.transient } return "done" } + // then XCTAssertEqual(result.value, "done") let collected = await receivedErrors.errors XCTAssertEqual(collected.count, 3) @@ -140,34 +145,39 @@ final class RetryPolicyServiceRetryWithResultTests: XCTestCase { } func test_retryWithResult_customStrategyOverridesDefault() async throws { + // given let sut = RetryPolicyService(strategy: .constant(retry: 10, dispatchDuration: .milliseconds(10))) let customStrategy = RetryPolicyStrategy.constant(retry: 2, dispatchDuration: .milliseconds(10)) let counter = Counter() + // when do { _ = try await sut.retryWithResult(strategy: customStrategy) { - await counter.increment() + counter.increment() throw TestError.transient } XCTFail("Expected retryLimitExceeded") } catch RetryPolicyError.retryLimitExceeded { - let count = await counter.count + let count = counter.value XCTAssertLessThanOrEqual(count, 3) } } func test_retryWithResult_totalDurationIsNonNegative() async throws { + // given let sut = RetryPolicyService(strategy: .constant(retry: 3, dispatchDuration: .milliseconds(10))) let counter = Counter() + // when let result = try await sut.retryWithResult { - await counter.increment() - if await counter.count < 2 { throw TestError.transient } + counter.increment() + if counter.value < 2 { throw TestError.transient } return true } + // then XCTAssertGreaterThanOrEqual(result.totalDuration, 0) } } diff --git a/Tests/TyphoonTests/UnitTests/RetryPolicyServiceTests.swift b/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceTests.swift similarity index 90% rename from Tests/TyphoonTests/UnitTests/RetryPolicyServiceTests.swift rename to Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceTests.swift index 167e4cd..faa5db7 100644 --- a/Tests/TyphoonTests/UnitTests/RetryPolicyServiceTests.swift +++ b/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceTests.swift @@ -17,7 +17,13 @@ final class RetryPolicyServiceTests: XCTestCase { override func setUp() { super.setUp() - sut = RetryPolicyService(strategy: .constant(retry: .defaultRetryCount, dispatchDuration: .seconds(0))) + + sut = RetryPolicyService( + strategy: .constant( + retry: .defaultRetryCount, + dispatchDuration: .seconds(0) + ) + ) } override func tearDown() { @@ -88,9 +94,9 @@ final class RetryPolicyServiceTests: XCTestCase { let result = try await sut.retry( strategy: .constant(retry: .defaultRetryCount, dispatchDuration: .nanoseconds(1)) ) { - let currentCount = await counter.increment() + let currentCount = counter.increment() - if currentCount >= .defaultRetryCount { + if UInt(currentCount) >= .defaultRetryCount { return expectedValue } throw URLError(.unknown) @@ -98,8 +104,8 @@ final class RetryPolicyServiceTests: XCTestCase { // then XCTAssertEqual(result, expectedValue) - let finalCount = await counter.getValue() - XCTAssertEqual(finalCount, .defaultRetryCount) + let finalCount = counter.getValue() + XCTAssertEqual(UInt(finalCount), .defaultRetryCount) } // MARK: Tests - Retry Count @@ -113,13 +119,13 @@ final class RetryPolicyServiceTests: XCTestCase { _ = try await sut.retry( strategy: .constant(retry: .defaultRetryCount, dispatchDuration: .nanoseconds(1)) ) { - _ = await counter.increment() + counter.increment() throw URLError(.unknown) } } catch {} // then - let attemptCount = await counter.getValue() + let attemptCount = counter.getValue() XCTAssertEqual(attemptCount, .defaultRetryCount + 1) } @@ -133,13 +139,13 @@ final class RetryPolicyServiceTests: XCTestCase { strategy: .constant(retry: .defaultRetryCount, dispatchDuration: .nanoseconds(1)), onFailure: { _ in false } ) { - _ = await counter.increment() + counter.increment() throw URLError(.unknown) } } catch {} // then - let attemptCount = await counter.getValue() + let attemptCount = counter.getValue() XCTAssertEqual(attemptCount, 1) } @@ -181,13 +187,13 @@ final class RetryPolicyServiceTests: XCTestCase { true } ) { - _ = await counter.increment() + counter.increment() throw URLError(.unknown) } } catch {} // then - let callCount = await counter.getValue() + let callCount = counter.getValue() XCTAssertEqual(callCount, expectedCallCount + 1) } @@ -248,7 +254,7 @@ final class RetryPolicyServiceTests: XCTestCase { return true } ) { - let index = await counter.increment() - 1 + let index = counter.increment() - 1 throw errors[min(Int(index), errors.count - 1)] } } catch {} @@ -309,7 +315,7 @@ final class RetryPolicyServiceTests: XCTestCase { // when let result = try await service.retry { - let count = await counter.increment() + let count = counter.increment() if count >= 3 { return expectedValue } throw URLError(.unknown) } @@ -351,13 +357,13 @@ final class RetryPolicyServiceTests: XCTestCase { // when do { _ = try await service.retry { - _ = await counter.increment() + counter.increment() throw URLError(.unknown) } } catch {} // then - let attempts = await counter.getValue() + let attempts = counter.getValue() XCTAssertEqual(attempts, .defaultRetryCount + 1) } @@ -377,36 +383,17 @@ final class RetryPolicyServiceTests: XCTestCase { // when do { _ = try await service.retry { - _ = await counter.increment() + counter.increment() throw URLError(.unknown) } } catch {} // then - let attempts = await counter.getValue() + let attempts = counter.getValue() XCTAssertEqual(attempts, 6) } } -// MARK: - Counter - -private actor Counter { - // MARK: Properties - - private var value: UInt = 0 - - // MARK: Internal - - func increment() -> UInt { - value += 1 - return value - } - - func getValue() -> UInt { - value - } -} - // MARK: - ErrorContainer private actor ErrorContainer { diff --git a/Tests/TyphoonTests/UnitTests/RetrySequenceTests.swift b/Tests/TyphoonTests/UnitTests/RetryService/RetrySequenceTests.swift similarity index 100% rename from Tests/TyphoonTests/UnitTests/RetrySequenceTests.swift rename to Tests/TyphoonTests/UnitTests/RetryService/RetrySequenceTests.swift diff --git a/Tests/TyphoonTests/UnitTests/ExponentialDelayStrategyTests.swift b/Tests/TyphoonTests/UnitTests/Strategies/ExponentialDelayStrategyTests.swift similarity index 100% rename from Tests/TyphoonTests/UnitTests/ExponentialDelayStrategyTests.swift rename to Tests/TyphoonTests/UnitTests/Strategies/ExponentialDelayStrategyTests.swift diff --git a/Tests/TyphoonTests/UnitTests/FibonacciDelayStrategyTests.swift b/Tests/TyphoonTests/UnitTests/Strategies/FibonacciDelayStrategyTests.swift similarity index 100% rename from Tests/TyphoonTests/UnitTests/FibonacciDelayStrategyTests.swift rename to Tests/TyphoonTests/UnitTests/Strategies/FibonacciDelayStrategyTests.swift diff --git a/Tests/TyphoonTests/UnitTests/LinearDelayStrategyTests.swift b/Tests/TyphoonTests/UnitTests/Strategies/LinearDelayStrategyTests.swift similarity index 100% rename from Tests/TyphoonTests/UnitTests/LinearDelayStrategyTests.swift rename to Tests/TyphoonTests/UnitTests/Strategies/LinearDelayStrategyTests.swift diff --git a/Tests/TyphoonTests/UnitTests/RetryPolicyStrategyDurationTests.swift b/Tests/TyphoonTests/UnitTests/Strategies/RetryPolicyStrategyDurationTests.swift similarity index 100% rename from Tests/TyphoonTests/UnitTests/RetryPolicyStrategyDurationTests.swift rename to Tests/TyphoonTests/UnitTests/Strategies/RetryPolicyStrategyDurationTests.swift diff --git a/Tests/TyphoonTests/UnitTests/URLSession/URLSessionRetryPolicyTests.swift b/Tests/TyphoonTests/UnitTests/URLSession/URLSessionRetryPolicyTests.swift new file mode 100644 index 0000000..b6500cf --- /dev/null +++ b/Tests/TyphoonTests/UnitTests/URLSession/URLSessionRetryPolicyTests.swift @@ -0,0 +1,292 @@ +// +// Typhoon +// Copyright © 2026 Space Code. All rights reserved. +// + +#if canImport(Darwin) + @testable import Typhoon + import XCTest + + // MARK: - URLSessionRetryPolicyTests + + final class URLSessionRetryPolicyTests: XCTestCase { + // MARK: Properties + + private var sut: URLSession! + + // MARK: Setup + + override func setUp() async throws { + try await super.setUp() + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + sut = URLSession(configuration: config) + await MockURLProtocolHandler.shared.set(nil) + } + + override func tearDown() async throws { + sut = nil + await MockURLProtocolHandler.shared.set(nil) + try await super.tearDown() + } + + // MARK: Tests + + func test_dataForRequest_succeedsOnFirstAttempt() async throws { + // given + let counter = Counter() + + await setHandler { + counter.increment() + return (.ok, Data(String.data.utf8)) + } + + // when + let (data, _) = try await sut.data( + for: .stub, + retryPolicy: .constant(retry: 3, dispatchDuration: .milliseconds(1)) + ) + + // then + XCTAssertEqual(String(data: data, encoding: .utf8), String.data) + XCTAssertEqual(counter.value, 1) + } + + func test_dataForRequest_retriesAndSucceedsOnSecondAttempt() async throws { + // given + let counter = Counter() + + await setHandler { + let count = counter.increment() + if count < 2 { throw URLError(.notConnectedToInternet) } + return (.ok, Data(String.data.utf8)) + } + + // when + let (data, _) = try await sut.data( + for: .stub, + retryPolicy: .constant(retry: 3, dispatchDuration: .milliseconds(1)) + ) + + // then + XCTAssertEqual(counter.value, 2) + XCTAssertEqual(String(data: data, encoding: .utf8), String.data) + } + + func test_dataForRequest_throwsRetryLimitExceeded_whenAllAttemptsFail() async throws { + // given + await setHandler { throw URLError(.notConnectedToInternet) } + + // when / then + do { + _ = try await sut.data( + for: .stub, + retryPolicy: .constant(retry: 2, dispatchDuration: .milliseconds(1)) + ) + XCTFail("Expected RetryPolicyError.retryLimitExceeded to be thrown") + } catch RetryPolicyError.retryLimitExceeded { + // success + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func test_dataForRequest_stopsRetrying_whenOnFailureReturnsFalse() async throws { + // given + let counter = Counter() + + await setHandler { + counter.increment() + throw URLError(.badServerResponse) + } + + // when / then + do { + _ = try await sut.data( + for: .stub, + retryPolicy: .constant(retry: 5, dispatchDuration: .milliseconds(1)), + onFailure: { _ in false } + ) + XCTFail("Expected URLError to be thrown") + } catch is URLError { + // success + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertEqual(counter.value, 1, "Should not retry when onFailure returns false") + } + + func test_dataForRequest_onFailure_isCalledOnEachFailure() async throws { + // given + let counter = Counter() + + await setHandler { throw URLError(.notConnectedToInternet) } + + // when / then + do { + _ = try await sut.data( + for: .stub, + retryPolicy: .constant(retry: 3, dispatchDuration: .milliseconds(1)), + onFailure: { _ in + counter.increment() + return true + } + ) + XCTFail("Expected RetryPolicyError.retryLimitExceeded to be thrown") + } catch RetryPolicyError.retryLimitExceeded { + // success + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertEqual(counter.value, 4) + } + + func test_dataFromURL_retriesAndSucceeds() async throws { + // given + let counter = Counter() + + await setHandler { + let count = counter.increment() + if count < 3 { throw URLError(.timedOut) } + return (.ok, Data(String.data.utf8)) + } + + let url = try XCTUnwrap(URL(string: "https://stub.test")) + + // when + let (data, _) = try await sut.data( + from: url, + retryPolicy: .constant(retry: 3, dispatchDuration: .milliseconds(1)) + ) + + // then + XCTAssertEqual(counter.value, 3) + XCTAssertEqual(String(data: data, encoding: .utf8), String.data) + } + + #if !os(watchOS) + func test_upload_succeedsAfterRetry() async throws { + // given + let counter = Counter() + + await setHandler { + let count = counter.increment() + if count < 2 { throw URLError(.networkConnectionLost) } + return (.ok, Data()) + } + + // when + _ = try await sut.upload( + for: .stub, + from: Data(String.data.utf8), + retryPolicy: .constant(retry: 3, dispatchDuration: .milliseconds(1)) + ) + + // then + XCTAssertEqual(counter.value, 2) + } + #endif + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + func test_download_succeedsAfterRetry() async throws { + // given + let counter = Counter() + + await setHandler { + let count = counter.increment() + if count < 2 { throw URLError(.timedOut) } + return (.ok, Data(String.data.utf8)) + } + + // when + let (url, _) = try await sut.download( + for: .stub, + retryPolicy: .constant(retry: 3, dispatchDuration: .milliseconds(1)) + ) + + // then + XCTAssertEqual(counter.value, 2) + let content = try String(contentsOf: url, encoding: .utf8) + XCTAssertEqual(content, String.data) + } + + func test_linearStrategy_retriesCorrectNumberOfTimes() async throws { + // given + let counter = Counter() + + await setHandler { + counter.increment() + throw URLError(.notConnectedToInternet) + } + + // when / then + do { + _ = try await sut.data( + for: .stub, + retryPolicy: .linear(retry: 4, dispatchDuration: .milliseconds(1)) + ) + XCTFail("Expected RetryPolicyError.retryLimitExceeded to be thrown") + } catch RetryPolicyError.retryLimitExceeded { + // success + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertEqual(counter.value, 5) + } + + func test_chainStrategy_retriesCorrectNumberOfTimes() async throws { + // given + let counter = Counter() + + await setHandler { + counter.increment() + throw URLError(.notConnectedToInternet) + } + + let strategy = RetryPolicyStrategy.chain([ + .init(retries: 2, strategy: ConstantDelayStrategy(duration: .milliseconds(1))), + .init(retries: 3, strategy: ConstantDelayStrategy(duration: .milliseconds(1))), + ]) + + // when / then + do { + _ = try await sut.data(for: .stub, retryPolicy: strategy) + XCTFail("Expected RetryPolicyError.retryLimitExceeded to be thrown") + } catch RetryPolicyError.retryLimitExceeded { + // success + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertEqual(counter.value, 6) + } + + // MARK: Helpers + + private func setHandler(_ handler: MockURLProtocolHandler.Handler?) async { + await MockURLProtocolHandler.shared.set(handler) + } + } + + // MARK: - Constants + + private extension HTTPURLResponse { + static let ok = HTTPURLResponse( + url: URL(string: "https://stub.test")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + } + + private extension URLRequest { + static let stub = URLRequest(url: URL(string: "https://stub.test")!) + } + + private extension String { + static let data = "hello" + } +#endif