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")