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
95 changes: 95 additions & 0 deletions Sources/Typhoon/Classes/Extensions/URLSession+RetryPolicy.swift
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions Tests/TyphoonTests/Helpers/Counter.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
36 changes: 36 additions & 0 deletions Tests/TyphoonTests/Mocks/MockURLProtocol.swift
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions Tests/TyphoonTests/Mocks/MockURLProtocolHandler.swift
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -16,53 +16,51 @@ 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)
XCTAssertGreaterThanOrEqual(result.totalDuration, 0)
}

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)
XCTAssertTrue(result.errors.allSatisfy { ($0 as? TestError) == .transient })
}

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
Expand All @@ -72,102 +70,114 @@ 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)
XCTAssertEqual(result.errors.count, 3)
}

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)
}
}
Expand Down
Loading
Loading