Skip to content
Merged
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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)" }
Expand Down
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
}
}
15 changes: 13 additions & 2 deletions Sources/Typhoon/Classes/Model/RetryPolicyError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
6 changes: 6 additions & 0 deletions Sources/Typhoon/Classes/Model/RetryResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

import Foundation

// MARK: - RetryResult

/// Represents the result of executing a closure with a retry policy.
public struct RetryResult<T> {
/// The successfully returned value from the closure.
Expand All @@ -19,3 +21,7 @@ public struct RetryResult<T> {
/// List of errors encountered during each failed attempt.
public let errors: [Error]
}

// MARK: Sendable

extension RetryResult: Sendable where T: Sendable {}
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,21 @@ 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

/// Retries a closure and returns a detailed `RetryResult` including success/failure info.
///
/// - 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.
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 @@ -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()
Expand Down Expand Up @@ -98,23 +98,35 @@ 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
attempt: Int,
collectedErrors: [Error]
) 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(errors: collectedErrors)
}

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(errors: collectedErrors)
}

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,13 +153,14 @@ 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
var iterator = RetrySequence(strategy: effectiveStrategy).makeIterator()
let deadline = calculateDeadline()
var attempt = 0
var collectedErrors: [Error] = []

while true {
try checkDeadline(deadline, attempt: attempt)
Expand All @@ -158,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
)
}
}
Expand All @@ -180,7 +195,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 +205,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 @@ -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
}
}

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
Loading
Loading