From 45e29e12391a58635ede7f35b65141a11e1dd385 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Tue, 5 May 2026 18:09:13 +0400 Subject: [PATCH 1/5] feat: add ability to skip retry delay - Introduce `RetryAction` enum to specify post-failure behavior (.retry, .skipDelay, .stop). - Update `IRetryPolicyService` and `RetryPolicyService` to use `RetryAction` in `onFailure` closures. - Implement immediate retry logic when `.skipDelay` is returned. - Maintain backward compatibility for boolean literals via `ExpressibleByBooleanLiteral`. - Update `URLSession` extensions and unit tests to support the new API. --- .../Extensions/URLSession+RetryPolicy.swift | 16 ++++---- .../Typhoon/Classes/Model/RetryAction.swift | 22 ++++++++++ .../IRetryPolicyService.swift | 11 +++-- .../RetryPolicyService.swift | 41 ++++++++++++------- .../RetryPolicyServiceLoggerTests.swift | 4 +- ...tryPolicyServiceRetryWithResultTests.swift | 6 +-- .../RetryPolicyServiceTests.swift | 38 ++++++++++++++--- .../URLSessionRetryPolicyTests.swift | 4 +- 8 files changed, 103 insertions(+), 39 deletions(-) create mode 100644 Sources/Typhoon/Classes/Model/RetryAction.swift diff --git a/Sources/Typhoon/Classes/Extensions/URLSession+RetryPolicy.swift b/Sources/Typhoon/Classes/Extensions/URLSession+RetryPolicy.swift index 8ac42e2..e17b7f2 100644 --- a/Sources/Typhoon/Classes/Extensions/URLSession+RetryPolicy.swift +++ b/Sources/Typhoon/Classes/Extensions/URLSession+RetryPolicy.swift @@ -12,12 +12,12 @@ /// - 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. + /// - onFailure: An optional closure called on each failure. Return `.stop` to stop retrying early. /// - Returns: A tuple of `(Data, URLResponse)`. func data( for request: URLRequest, retryPolicy strategy: RetryPolicyStrategy, - onFailure: (@Sendable (Error) async -> Bool)? = nil + onFailure: (@Sendable (Error) async -> RetryAction)? = nil ) async throws -> (Data, URLResponse) { try await RetryPolicyService(strategy: strategy).retry( strategy: nil, @@ -32,12 +32,12 @@ /// - 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. + /// - onFailure: An optional closure called on each failure. Return `.stop` to stop retrying early. /// - Returns: A tuple of `(Data, URLResponse)`. func data( from url: URL, retryPolicy strategy: RetryPolicyStrategy, - onFailure: (@Sendable (Error) async -> Bool)? = nil + onFailure: (@Sendable (Error) async -> RetryAction)? = nil ) async throws -> (Data, URLResponse) { try await RetryPolicyService(strategy: strategy).retry( strategy: nil, @@ -53,13 +53,13 @@ /// - 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. + /// - onFailure: An optional closure called on each failure. Return `.stop` 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 + onFailure: (@Sendable (Error) async -> RetryAction)? = nil ) async throws -> (Data, URLResponse) { try await RetryPolicyService(strategy: strategy).retry( strategy: nil, @@ -75,14 +75,14 @@ /// - 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. + /// - onFailure: An optional closure called on each failure. Return `.stop` 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 + onFailure: (@Sendable (Error) async -> RetryAction)? = nil ) async throws -> (URL, URLResponse) { try await RetryPolicyService(strategy: strategy).retry( strategy: nil, diff --git a/Sources/Typhoon/Classes/Model/RetryAction.swift b/Sources/Typhoon/Classes/Model/RetryAction.swift new file mode 100644 index 0000000..48011bd --- /dev/null +++ b/Sources/Typhoon/Classes/Model/RetryAction.swift @@ -0,0 +1,22 @@ +// +// Typhoon +// Copyright © 2026 Space Code. All rights reserved. +// + +import Foundation + +/// Represents the action to take after a failed attempt. +public enum RetryAction: Sendable, ExpressibleByBooleanLiteral { + /// Retry the operation according to the strategy (with delay). + case retry + /// Retry the operation immediately, skipping the strategy's delay. + case skipDelay + /// Stop retrying and rethrow the last error. + case stop + + // MARK: Initialization + + public init(booleanLiteral value: Bool) { + self = value ? .retry : .stop + } +} diff --git a/Sources/Typhoon/Classes/RetryPolicyService/IRetryPolicyService.swift b/Sources/Typhoon/Classes/RetryPolicyService/IRetryPolicyService.swift index 9465da8..77d98b0 100644 --- a/Sources/Typhoon/Classes/RetryPolicyService/IRetryPolicyService.swift +++ b/Sources/Typhoon/Classes/RetryPolicyService/IRetryPolicyService.swift @@ -19,7 +19,7 @@ public protocol IRetryPolicyService: Sendable { /// - Returns: The result of the closure's execution after retrying based on the policy. func retry( strategy: RetryPolicyStrategy?, - onFailure: (@Sendable (Error) async -> Bool)?, + onFailure: (@Sendable (Error) async -> RetryAction)?, _ closure: @Sendable () async throws -> T ) async throws -> T @@ -33,7 +33,7 @@ public protocol IRetryPolicyService: Sendable { /// - Returns: A `RetryResult` containing the final value, attempt count, total duration, and encountered errors. func retryWithResult( strategy: RetryPolicyStrategy?, - onFailure: (@Sendable (Error) async -> Bool)?, + onFailure: (@Sendable (Error) async -> RetryAction)?, _ closure: @Sendable () async throws -> T ) async throws -> RetryResult } @@ -67,7 +67,10 @@ public extension IRetryPolicyService { /// - closure: The closure that will be retried based on the specified strategy. /// /// - Returns: The result of the closure's execution after retrying based on the policy. - func retry(_ closure: @Sendable () async throws -> T, onFailure: (@Sendable (Error) async -> Bool)?) async throws -> T { + func retry( + _ closure: @Sendable () async throws -> T, + onFailure: (@Sendable (Error) async -> RetryAction)? + ) async throws -> T { try await retry(strategy: nil, onFailure: onFailure, closure) } @@ -91,7 +94,7 @@ public extension IRetryPolicyService { /// /// - Returns: A `RetryResult` containing the final value, attempt count, total duration, and encountered errors. func retryWithResult( - onFailure: (@Sendable (Error) async -> Bool)?, + onFailure: (@Sendable (Error) async -> RetryAction)?, _ closure: @Sendable () async throws -> T ) async throws -> RetryResult { try await retryWithResult(strategy: nil, onFailure: onFailure, closure) diff --git a/Sources/Typhoon/Classes/RetryPolicyService/RetryPolicyService.swift b/Sources/Typhoon/Classes/RetryPolicyService/RetryPolicyService.swift index 539aaee..ca2cacf 100644 --- a/Sources/Typhoon/Classes/RetryPolicyService/RetryPolicyService.swift +++ b/Sources/Typhoon/Classes/RetryPolicyService/RetryPolicyService.swift @@ -98,23 +98,34 @@ public final class RetryPolicyService { private func handleRetryDecision( error: Error, - onFailure: (@Sendable (Error) async -> Bool)?, + onFailure: (@Sendable (Error) async -> RetryAction)?, iterator: inout some IteratorProtocol, attempt: Int ) async throws { - if let onFailure, await !onFailure(error) { - logger?.warning("[RetryPolicy] Stopped retrying after \(attempt) attempt(s) — onFailure returned false.") - throw error - } + let action = await onFailure?(error) ?? .retry - guard let duration = iterator.next() else { - logger?.error("[RetryPolicy] Retry limit exceeded after \(attempt) attempt(s).") - throw RetryPolicyError.retryLimitExceeded - } + switch action { + case .retry: + guard let duration = iterator.next() else { + logger?.error("[RetryPolicy] Retry limit exceeded after \(attempt) attempt(s).") + throw RetryPolicyError.retryLimitExceeded + } - logger?.info("[RetryPolicy] Waiting \(duration)ns before attempt \(attempt + 1)...") - try Task.checkCancellation() - try await Task.sleep(nanoseconds: duration) + logger?.info("[RetryPolicy] Waiting \(duration)ns before attempt \(attempt + 1)...") + try Task.checkCancellation() + try await Task.sleep(nanoseconds: duration) + case .skipDelay: + guard iterator.next() != nil else { + logger?.error("[RetryPolicy] Retry limit exceeded after \(attempt) attempt(s).") + throw RetryPolicyError.retryLimitExceeded + } + + logger?.info("[RetryPolicy] Retrying attempt \(attempt + 1) immediately (delay skipped).") + try Task.checkCancellation() + case .stop: + logger?.warning("[RetryPolicy] Stopped retrying after \(attempt) attempt(s) — onFailure returned stop.") + throw error + } } private func logSuccess(attempt: Int) { @@ -141,7 +152,7 @@ extension RetryPolicyService: IRetryPolicyService { /// - Returns: The result of the closure's execution after retrying based on the policy. public func retry( strategy: RetryPolicyStrategy?, - onFailure: (@Sendable (Error) async -> Bool)?, + onFailure: (@Sendable (Error) async -> RetryAction)?, _ closure: @Sendable () async throws -> T ) async throws -> T { let effectiveStrategy = strategy ?? self.strategy @@ -180,7 +191,7 @@ extension RetryPolicyService: IRetryPolicyService { /// - Returns: A `RetryResult` containing the final value, attempt count, total duration, and encountered errors. public func retryWithResult( strategy: RetryPolicyStrategy? = nil, - onFailure: (@Sendable (Error) async -> Bool)? = nil, + onFailure: (@Sendable (Error) async -> RetryAction)? = nil, _ closure: @Sendable () async throws -> T ) async throws -> RetryResult { let state = State() @@ -190,7 +201,7 @@ extension RetryPolicyService: IRetryPolicyService { strategy: strategy, onFailure: { error in await state.recordError(error) - return await onFailure?(error) ?? true + return await onFailure?(error) ?? .retry }, { await state.recordAttempt() return try await closure() diff --git a/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceLoggerTests.swift b/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceLoggerTests.swift index de5693c..1df6bc5 100644 --- a/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceLoggerTests.swift +++ b/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceLoggerTests.swift @@ -85,13 +85,13 @@ final class RetryPolicyServiceLoggerTests: XCTestCase { // when _ = try? await sut.retry( strategy: nil, - onFailure: { _ in false } + onFailure: { _ in .stop } ) { throw URLError(.badServerResponse) } // then - XCTAssertTrue(logger.warningMessages.contains { $0.contains("onFailure returned false") }) + XCTAssertTrue(logger.warningMessages.contains { $0.contains("onFailure returned stop") }) } func test_logsError_onTotalDurationExceeded() async { diff --git a/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceRetryWithResultTests.swift b/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceRetryWithResultTests.swift index 3db949a..7ce6af4 100644 --- a/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceRetryWithResultTests.swift +++ b/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceRetryWithResultTests.swift @@ -78,7 +78,7 @@ final class RetryPolicyServiceRetryWithResultTests: XCTestCase { // when do { _ = try await sut.retryWithResult( - onFailure: { _ in false } + onFailure: { _ in .stop } ) { counter.increment() throw TestError.fatal @@ -101,7 +101,7 @@ final class RetryPolicyServiceRetryWithResultTests: XCTestCase { do { _ = try await sut.retryWithResult( onFailure: { error in - (error as? TestError) == .transient + (error as? TestError) == .transient ? .retry : .stop } ) { counter.increment() @@ -127,7 +127,7 @@ final class RetryPolicyServiceRetryWithResultTests: XCTestCase { let result = try await sut.retryWithResult( onFailure: { error in await receivedErrors.append(error) - return true + return .retry } ) { counter.increment() diff --git a/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceTests.swift b/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceTests.swift index faa5db7..4e9ae8c 100644 --- a/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceTests.swift +++ b/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceTests.swift @@ -58,7 +58,7 @@ final class RetryPolicyServiceTests: XCTestCase { do { _ = try await sut.retry( strategy: .constant(retry: .defaultRetryCount, dispatchDuration: .nanoseconds(1)), - onFailure: { _ in false } + onFailure: { _ in .stop } ) { throw originalError } @@ -137,7 +137,7 @@ final class RetryPolicyServiceTests: XCTestCase { do { _ = try await sut.retry( strategy: .constant(retry: .defaultRetryCount, dispatchDuration: .nanoseconds(1)), - onFailure: { _ in false } + onFailure: { _ in .stop } ) { counter.increment() throw URLError(.unknown) @@ -162,7 +162,7 @@ final class RetryPolicyServiceTests: XCTestCase { strategy: .constant(retry: .defaultRetryCount, dispatchDuration: .nanoseconds(1)), onFailure: { error in await errorContainer.setError(error as NSError) - return false + return .stop } ) { throw expectedError @@ -184,7 +184,7 @@ final class RetryPolicyServiceTests: XCTestCase { _ = try await sut.retry( strategy: .constant(retry: expectedCallCount, dispatchDuration: .nanoseconds(1)), onFailure: { _ in - true + .retry } ) { counter.increment() @@ -251,7 +251,7 @@ final class RetryPolicyServiceTests: XCTestCase { strategy: .constant(retry: UInt(errors.count), dispatchDuration: .nanoseconds(1)), onFailure: { error in await errorContainer.setError(error as NSError) - return true + return .retry } ) { let index = counter.increment() - 1 @@ -367,6 +367,34 @@ final class RetryPolicyServiceTests: XCTestCase { XCTAssertEqual(attempts, .defaultRetryCount + 1) } + func test_thatRetrySkipsDelay_whenOnFailureReturnsSkipDelay() async throws { + // given + let counter = Counter() + let strategy = RetryPolicyStrategy.constant(retry: 1, dispatchDuration: .seconds(10)) // 10s delay + let service = RetryPolicyService(strategy: strategy) + let startTime = Date() + + // when + do { + _ = try await service.retry( + strategy: nil, + onFailure: { _ in .skipDelay } + ) { + if counter.increment() == 1 { + throw URLError(.unknown) + } + return 42 + } + } catch { + XCTFail("Should not throw error") + } + + // then + let duration = Date().timeIntervalSince(startTime) + XCTAssertLessThan(duration, 1.0, "Retry should have skipped the 10s delay") + XCTAssertEqual(counter.getValue(), 2) + } + func test_thatChainDelayStrategy_worksWithRetryPolicyService() async throws { // given let counter = Counter() diff --git a/Tests/TyphoonTests/UnitTests/URLSession/URLSessionRetryPolicyTests.swift b/Tests/TyphoonTests/UnitTests/URLSession/URLSessionRetryPolicyTests.swift index b6500cf..b67573d 100644 --- a/Tests/TyphoonTests/UnitTests/URLSession/URLSessionRetryPolicyTests.swift +++ b/Tests/TyphoonTests/UnitTests/URLSession/URLSessionRetryPolicyTests.swift @@ -105,7 +105,7 @@ _ = try await sut.data( for: .stub, retryPolicy: .constant(retry: 5, dispatchDuration: .milliseconds(1)), - onFailure: { _ in false } + onFailure: { _ in .stop } ) XCTFail("Expected URLError to be thrown") } catch is URLError { @@ -130,7 +130,7 @@ retryPolicy: .constant(retry: 3, dispatchDuration: .milliseconds(1)), onFailure: { _ in counter.increment() - return true + return .retry } ) XCTFail("Expected RetryPolicyError.retryLimitExceeded to be thrown") From d1de70217d7453b9c1d1e524ccf681f803f4d9e3 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Wed, 6 May 2026 14:11:46 +0400 Subject: [PATCH 2/5] feat(error): add collected errors to retryLimitExceeded case --- .../Classes/Model/RetryPolicyError.swift | 15 +++++- .../RetryPolicyService.swift | 12 +++-- .../RetryPolicyServiceTests.swift | 54 +++++++++++++++++-- 3 files changed, 70 insertions(+), 11 deletions(-) diff --git a/Sources/Typhoon/Classes/Model/RetryPolicyError.swift b/Sources/Typhoon/Classes/Model/RetryPolicyError.swift index c5c463b..e3c0365 100644 --- a/Sources/Typhoon/Classes/Model/RetryPolicyError.swift +++ b/Sources/Typhoon/Classes/Model/RetryPolicyError.swift @@ -6,9 +6,20 @@ import Foundation /// `RetryPolicyError` is the error type returned by Typhoon. -public enum RetryPolicyError: Error { +public enum RetryPolicyError: Error, Equatable { + public static func == (lhs: RetryPolicyError, rhs: RetryPolicyError) -> Bool { + switch (lhs, rhs) { + case let (.retryLimitExceeded(lhsErrors), .retryLimitExceeded(rhsErrors)): + lhsErrors.map { $0 as NSError } == rhsErrors.map { $0 as NSError } + case (.totalDurationExceeded, .totalDurationExceeded): + true + default: + false + } + } + /// The retry limit for attempts to perform a request has been exceeded. - case retryLimitExceeded + case retryLimitExceeded(errors: [Error]) /// Thrown when the total allowed duration for retries has been exceeded. case totalDurationExceeded } diff --git a/Sources/Typhoon/Classes/RetryPolicyService/RetryPolicyService.swift b/Sources/Typhoon/Classes/RetryPolicyService/RetryPolicyService.swift index ca2cacf..9617bc1 100644 --- a/Sources/Typhoon/Classes/RetryPolicyService/RetryPolicyService.swift +++ b/Sources/Typhoon/Classes/RetryPolicyService/RetryPolicyService.swift @@ -100,7 +100,8 @@ public final class RetryPolicyService { error: Error, onFailure: (@Sendable (Error) async -> RetryAction)?, iterator: inout some IteratorProtocol, - attempt: Int + attempt: Int, + collectedErrors: [Error] ) async throws { let action = await onFailure?(error) ?? .retry @@ -108,7 +109,7 @@ public final class RetryPolicyService { case .retry: guard let duration = iterator.next() else { logger?.error("[RetryPolicy] Retry limit exceeded after \(attempt) attempt(s).") - throw RetryPolicyError.retryLimitExceeded + throw RetryPolicyError.retryLimitExceeded(errors: collectedErrors) } logger?.info("[RetryPolicy] Waiting \(duration)ns before attempt \(attempt + 1)...") @@ -117,7 +118,7 @@ public final class RetryPolicyService { case .skipDelay: guard iterator.next() != nil else { logger?.error("[RetryPolicy] Retry limit exceeded after \(attempt) attempt(s).") - throw RetryPolicyError.retryLimitExceeded + throw RetryPolicyError.retryLimitExceeded(errors: collectedErrors) } logger?.info("[RetryPolicy] Retrying attempt \(attempt + 1) immediately (delay skipped).") @@ -159,6 +160,7 @@ extension RetryPolicyService: IRetryPolicyService { var iterator = RetrySequence(strategy: effectiveStrategy).makeIterator() let deadline = calculateDeadline() var attempt = 0 + var collectedErrors: [Error] = [] while true { try checkDeadline(deadline, attempt: attempt) @@ -169,13 +171,15 @@ extension RetryPolicyService: IRetryPolicyService { return result } catch { attempt += 1 + collectedErrors.append(error) logFailure(attempt: attempt, error: error) try await handleRetryDecision( error: error, onFailure: onFailure, iterator: &iterator, - attempt: attempt + attempt: attempt, + collectedErrors: collectedErrors ) } } diff --git a/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceTests.swift b/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceTests.swift index 4e9ae8c..cc5b7ef 100644 --- a/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceTests.swift +++ b/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceTests.swift @@ -34,9 +34,6 @@ final class RetryPolicyServiceTests: XCTestCase { // MARK: Tests - Error Handling func test_thatRetryThrowsRetryLimitExceededError_whenAllRetriesFail() async throws { - // given - let expectedError = RetryPolicyError.retryLimitExceeded - // when var receivedError: Error? do { @@ -46,7 +43,12 @@ final class RetryPolicyServiceTests: XCTestCase { } // then - XCTAssertEqual(receivedError as? NSError, expectedError as NSError) + guard case let .retryLimitExceeded(errors) = receivedError as? RetryPolicyError else { + XCTFail("Expected retryLimitExceeded, got \(String(describing: receivedError))") + return + } + XCTAssertFalse(errors.isEmpty, "Collected errors should not be empty") + XCTAssertTrue(errors.allSatisfy { ($0 as? URLError)?.code == .unknown }) } func test_thatRetryThrowsOriginalError_whenOnFailureReturnsFalse() async throws { @@ -343,7 +345,10 @@ final class RetryPolicyServiceTests: XCTestCase { // then XCTAssertEqual(receivedError as? RetryPolicyError, .totalDurationExceeded) - XCTAssertNotEqual(receivedError as? RetryPolicyError, .retryLimitExceeded) + + if case .retryLimitExceeded = receivedError as? RetryPolicyError { + XCTFail("Expected totalDurationExceeded, not retryLimitExceeded") + } } func test_thatRetryIgnoresDeadline_whenMaxTotalDurationIsNil() async throws { @@ -420,6 +425,45 @@ final class RetryPolicyServiceTests: XCTestCase { let attempts = counter.getValue() XCTAssertEqual(attempts, 6) } + + func test_thatRetryLimitExceededContainsAllCollectedErrors_whenAllRetriesFail() async throws { + // given + let retryCount: UInt = 3 + let errors: [URLError] = [ + URLError(.badURL), + URLError(.timedOut), + URLError(.cannotFindHost), + URLError(.unknown), + ] + let counter = Counter() + let service = RetryPolicyService( + strategy: .constant(retry: retryCount, dispatchDuration: .nanoseconds(1)) + ) + + // when + var receivedError: Error? + do { + _ = try await service.retry { + let index = Int(counter.increment()) - 1 + throw errors[min(index, errors.count - 1)] + } + } catch { + receivedError = error + } + + // then + guard case let .retryLimitExceeded(collectedErrors) = receivedError as? RetryPolicyError else { + XCTFail("Expected retryLimitExceeded, got \(String(describing: receivedError))") + return + } + + XCTAssertEqual(collectedErrors.count, Int(retryCount) + 1) + XCTAssertTrue( + collectedErrors.enumerated().allSatisfy { + ($0.element as? URLError)?.code == errors[min($0.offset, errors.count - 1)].code + } + ) + } } // MARK: - ErrorContainer From dfe7bfa2ff74a3889b9ed078853212c586c2fa41 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Wed, 6 May 2026 14:13:21 +0400 Subject: [PATCH 3/5] chore: update `ci.yml` --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8def58..25f1587 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,22 +34,22 @@ jobs: matrix: include: # macOS - - { platform: macOS, name: "macOS 26, Xcode 26.0, Swift 6.2.0", xcode: "Xcode_26.1.1", runsOn: macOS-26, destination: "platform=macOS" } + - { platform: macOS, name: "macOS 26, Xcode 26.1.1, Swift 6.2.0", xcode: "Xcode_26.1.1", runsOn: macOS-26, destination: "platform=macOS" } - { platform: macOS, name: "macOS 14, Xcode 16.1, Swift 6.0.2", xcode: "Xcode_16.1", runsOn: macOS-14, destination: "platform=macOS" } - { platform: macOS, name: "macOS 14, Xcode 15.4, Swift 5.10", xcode: "Xcode_15.4", runsOn: macOS-14, destination: "platform=macOS" } # iOS - - { platform: iOS, name: "iOS 26.0", xcode: "Xcode_26.1.1", runsOn: macOS-26, destination: "OS=26.1,name=iPhone 17 Pro" } + - { platform: iOS, name: "iOS 26.1", xcode: "Xcode_26.1.1", runsOn: macOS-26, destination: "OS=26.1,name=iPhone 17 Pro" } - { platform: iOS, name: "iOS 18.1", xcode: "Xcode_16.1", runsOn: macOS-14, destination: "OS=18.1,name=iPhone 16 Pro" } - { platform: iOS, name: "iOS 17.4", xcode: "Xcode_15.3", runsOn: macOS-14, destination: "OS=17.4,name=iPhone 15 Pro" } # tvOS - - { platform: tvOS, name: "tvOS 26.0", xcode: "Xcode_26.1.1", runsOn: macOS-26, destination: "OS=26.1,name=Apple TV" } + - { platform: tvOS, name: "tvOS 26.1", xcode: "Xcode_26.1.1", runsOn: macOS-26, destination: "OS=26.1,name=Apple TV" } - { platform: tvOS, name: "tvOS 18.1", xcode: "Xcode_16.1", runsOn: macOS-14, destination: "OS=18.1,name=Apple TV" } - { platform: tvOS, name: "tvOS 17.4", xcode: "Xcode_15.3", runsOn: macOS-14, destination: "OS=17.4,name=Apple TV" } # watchOS - - { platform: watchOS, name: "watchOS 26.0", xcode: "Xcode_26.1.1", runsOn: macOS-26, destination: "OS=26.1,name=Apple Watch Ultra 3 (49mm)" } + - { platform: watchOS, name: "watchOS 26.1", xcode: "Xcode_26.1.1", runsOn: macOS-26, destination: "OS=26.1,name=Apple Watch Ultra 3 (49mm)" } - { platform: watchOS, name: "watchOS 11.1", xcode: "Xcode_16.1", runsOn: macOS-14, destination: "OS=11.1,name=Apple Watch Series 10 (46mm)" } - { platform: watchOS, name: "watchOS 10.5", xcode: "Xcode_15.3", runsOn: macOS-14, destination: "OS=10.5,name=Apple Watch Series 9 (45mm)" } - { platform: watchOS, name: "watchOS 10.4", xcode: "Xcode_15.3", runsOn: macOS-14, destination: "OS=10.4,name=Apple Watch Series 9 (45mm)" } From 5a6fe24156c336c9bd1d044182cf2ff399fa3707 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Wed, 6 May 2026 14:48:28 +0400 Subject: [PATCH 4/5] feat: mark `RetryResult` as a sendable object --- Sources/Typhoon/Classes/Model/RetryResult.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/Typhoon/Classes/Model/RetryResult.swift b/Sources/Typhoon/Classes/Model/RetryResult.swift index 4d3f7ef..129b43f 100644 --- a/Sources/Typhoon/Classes/Model/RetryResult.swift +++ b/Sources/Typhoon/Classes/Model/RetryResult.swift @@ -5,6 +5,8 @@ import Foundation +// MARK: - RetryResult + /// Represents the result of executing a closure with a retry policy. public struct RetryResult { /// The successfully returned value from the closure. @@ -19,3 +21,7 @@ public struct RetryResult { /// List of errors encountered during each failed attempt. public let errors: [Error] } + +// MARK: Sendable + +extension RetryResult: Sendable where T: Sendable {} From 63835242872b34b4e4f230b2301729c75913e2d6 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Wed, 6 May 2026 15:05:40 +0400 Subject: [PATCH 5/5] docs: update the documentation --- .../Classes/RetryPolicyService/IRetryPolicyService.swift | 2 +- .../Classes/RetryPolicyService/RetryPolicyService.swift | 6 +++--- .../Typhoon.docc/Articles/advanced-retry-strategies.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/Typhoon/Classes/RetryPolicyService/IRetryPolicyService.swift b/Sources/Typhoon/Classes/RetryPolicyService/IRetryPolicyService.swift index 77d98b0..d878911 100644 --- a/Sources/Typhoon/Classes/RetryPolicyService/IRetryPolicyService.swift +++ b/Sources/Typhoon/Classes/RetryPolicyService/IRetryPolicyService.swift @@ -27,7 +27,7 @@ public protocol IRetryPolicyService: Sendable { /// /// - Parameters: /// - strategy: Optional strategy that defines the retry behavior. - /// - onFailure: Optional closure called on each failure; returning `true` stops retries. + /// - onFailure: Optional closure called on each failure; returning `.stop` stops retries. /// - closure: The async closure to be retried according to the strategy. /// /// - Returns: A `RetryResult` containing the final value, attempt count, total duration, and encountered errors. diff --git a/Sources/Typhoon/Classes/RetryPolicyService/RetryPolicyService.swift b/Sources/Typhoon/Classes/RetryPolicyService/RetryPolicyService.swift index 9617bc1..ee8940b 100644 --- a/Sources/Typhoon/Classes/RetryPolicyService/RetryPolicyService.swift +++ b/Sources/Typhoon/Classes/RetryPolicyService/RetryPolicyService.swift @@ -32,9 +32,9 @@ import Foundation /// onFailure: { error in /// print("Request failed with error: \(error)") /// -/// // Return `true` to continue retrying, -/// // or `false` to stop and rethrow the error. -/// return true +/// // Return `.retry` to continue retrying, +/// // or `.stop` to stop and rethrow the error. +/// return .retry /// } /// ) { /// try await apiClient.fetchData() diff --git a/Sources/Typhoon/Typhoon.docc/Articles/advanced-retry-strategies.md b/Sources/Typhoon/Typhoon.docc/Articles/advanced-retry-strategies.md index 7aef4cd..cbc5563 100644 --- a/Sources/Typhoon/Typhoon.docc/Articles/advanced-retry-strategies.md +++ b/Sources/Typhoon/Typhoon.docc/Articles/advanced-retry-strategies.md @@ -153,10 +153,10 @@ func fetchWithConditionalRetry() async throws -> Data { switch error { case .serverError, .timeout, .connectionLost: // These errors were already retried - return true + return .retry case .clientError: // Don't retry client errors (4xx) - return false + return .stop } }