Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions Sources/Typhoon/Classes/Model/RetryAction.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public protocol IRetryPolicyService: Sendable {
/// - Returns: The result of the closure's execution after retrying based on the policy.
func retry<T>(
strategy: RetryPolicyStrategy?,
onFailure: (@Sendable (Error) async -> Bool)?,
onFailure: (@Sendable (Error) async -> RetryAction)?,
_ closure: @Sendable () async throws -> T
) async throws -> T

Expand All @@ -33,7 +33,7 @@ public protocol IRetryPolicyService: Sendable {
/// - Returns: A `RetryResult` containing the final value, attempt count, total duration, and encountered errors.
func retryWithResult<T>(
strategy: RetryPolicyStrategy?,
onFailure: (@Sendable (Error) async -> Bool)?,
onFailure: (@Sendable (Error) async -> RetryAction)?,
_ closure: @Sendable () async throws -> T
) async throws -> RetryResult<T>
}
Expand Down Expand Up @@ -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<T>(_ closure: @Sendable () async throws -> T, onFailure: (@Sendable (Error) async -> Bool)?) async throws -> T {
func retry<T>(
_ closure: @Sendable () async throws -> T,
onFailure: (@Sendable (Error) async -> RetryAction)?
) async throws -> T {
try await retry(strategy: nil, onFailure: onFailure, closure)
}

Expand All @@ -91,7 +94,7 @@ public extension IRetryPolicyService {
///
/// - Returns: A `RetryResult` containing the final value, attempt count, total duration, and encountered errors.
func retryWithResult<T>(
onFailure: (@Sendable (Error) async -> Bool)?,
onFailure: (@Sendable (Error) async -> RetryAction)?,
_ closure: @Sendable () async throws -> T
) async throws -> RetryResult<T> {
try await retryWithResult(strategy: nil, onFailure: onFailure, closure)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UInt64>,
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) {
Expand All @@ -141,7 +152,7 @@ extension RetryPolicyService: IRetryPolicyService {
/// - Returns: The result of the closure's execution after retrying based on the policy.
public func retry<T>(
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
Expand Down Expand Up @@ -180,7 +191,7 @@ extension RetryPolicyService: IRetryPolicyService {
/// - Returns: A `RetryResult` containing the final value, attempt count, total duration, and encountered errors.
public func retryWithResult<T>(
strategy: RetryPolicyStrategy? = nil,
onFailure: (@Sendable (Error) async -> Bool)? = nil,
onFailure: (@Sendable (Error) async -> RetryAction)? = nil,
_ closure: @Sendable () async throws -> T
) async throws -> RetryResult<T> {
let state = State()
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")
Expand Down
Loading