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
23 changes: 23 additions & 0 deletions Sources/Typhoon/Classes/Extensions/Logger+ILogger.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// Typhoon
// Copyright © 2026 Space Code. All rights reserved.
//

#if canImport(OSLog)
import OSLog

@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *)
extension Logger: ILogger {
public func info(_ message: String) {
info("\(message, privacy: .public)")
}

public func warning(_ message: String) {
warning("\(message, privacy: .public)")
}

public func error(_ message: String) {
error("\(message, privacy: .public)")
}
}
#endif
41 changes: 41 additions & 0 deletions Sources/Typhoon/Classes/Logger/ILogger.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//
// Typhoon
// Copyright © 2026 Space Code. All rights reserved.
//

import Foundation

// MARK: - ILogger

/// A protocol that abstracts logging functionality.
///
/// Conform to this protocol to provide a custom logging implementation,
/// or use the built-in `Logger` wrapper on Apple platforms.
///
/// ### Example
/// ```swift
/// struct PrintLogger: ILogger {
/// func info(_ message: @autoclosure () -> String) {
/// print("[INFO] \(message())")
/// }
/// func warning(_ message: @autoclosure () -> String) {
/// print("[WARNING] \(message())")
/// }
/// func error(_ message: @autoclosure () -> String) {
/// print("[ERROR] \(message())")
/// }
/// }
/// ```
public protocol ILogger: Sendable {
/// Logs an informational message.
/// - Parameter message: A closure that returns the message string (evaluated lazily).
func info(_ message: String)

/// Logs a warning message.
/// - Parameter message: A closure that returns the message string (evaluated lazily).
func warning(_ message: String)

/// Logs an error message.
/// - Parameter message: A closure that returns the message string (evaluated lazily).
func error(_ message: String)
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,72 @@ public final class RetryPolicyService {
/// Optional maximum total duration allowed for all retry attempts.
private let maxTotalDuration: DispatchTimeInterval?

/// An optional logger used to record retry attempts and related events.
private let logger: ILogger?

// MARK: Initialization

/// Initializes a new instance of `RetryPolicyService`.
///
/// - Parameters:
/// - strategy: The strategy that determines how retries are performed.
/// - maxTotalDuration: Optional maximum duration for all retries combined. If `nil`,
/// retries can continue indefinitely based on the
/// strategy.
public init(strategy: RetryPolicyStrategy, maxTotalDuration: DispatchTimeInterval? = nil) {
/// retries can continue indefinitely based on the strategy.
/// - logger: An optional logger for capturing retry-related information.
public init(
strategy: RetryPolicyStrategy,
maxTotalDuration: DispatchTimeInterval? = nil,
logger: ILogger? = nil
) {
self.strategy = strategy
self.maxTotalDuration = maxTotalDuration
self.logger = logger
}

// MARK: Private

private func calculateDeadline() -> Date? {
maxTotalDuration?.nanoseconds.map {
Date().addingTimeInterval(TimeInterval($0) / 1_000_000_000)
}
}

private func checkDeadline(_ deadline: Date?, attempt: Int) throws {
if let deadline, Date() > deadline {
logger?.error("[RetryPolicy] Total duration exceeded after \(attempt) attempt(s).")
throw RetryPolicyError.totalDurationExceeded
}
}

private func handleRetryDecision(
error: Error,
onFailure: (@Sendable (Error) async -> Bool)?,
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
}

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

private func logSuccess(attempt: Int) {
if attempt > 0 {
logger?.info("[RetryPolicy] Succeeded after \(attempt + 1) attempt(s).")
}
}

private func logFailure(attempt: Int, error: Error) {
logger?.warning("[RetryPolicy] Attempt \(attempt) failed: \(error.localizedDescription).")
}
}

Expand All @@ -91,34 +145,27 @@ extension RetryPolicyService: IRetryPolicyService {
_ closure: @Sendable () async throws -> T
) async throws -> T {
let effectiveStrategy = strategy ?? self.strategy

var iterator = RetrySequence(strategy: effectiveStrategy).makeIterator()

let deadline = maxTotalDuration?.nanoseconds.map {
Date().addingTimeInterval(TimeInterval($0) / 1_000_000_000)
}
let deadline = calculateDeadline()
var attempt = 0

while true {
if let deadline, Date() > deadline {
throw RetryPolicyError.totalDurationExceeded
}
try checkDeadline(deadline, attempt: attempt)

do {
return try await closure()
let result = try await closure()
logSuccess(attempt: attempt)
return result
} catch {
let shouldContinue = await onFailure?(error) ?? true

if !shouldContinue {
throw error
}

guard let duration = iterator.next() else {
throw RetryPolicyError.retryLimitExceeded
}

try Task.checkCancellation()

try await Task.sleep(nanoseconds: duration)
attempt += 1
logFailure(attempt: attempt, error: error)

try await handleRetryDecision(
error: error,
onFailure: onFailure,
iterator: &iterator,
attempt: attempt
)
}
}
}
Expand Down
59 changes: 59 additions & 0 deletions Tests/TyphoonTests/Mocks/MockLogger.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//
// Typhoon
// Copyright © 2026 Space Code. All rights reserved.
//

import Foundation
@testable import Typhoon

// MARK: - MockLogger

final class MockLogger: ILogger, @unchecked Sendable {
// MARK: Types

enum Level {
case info, warning, error
}

struct LogEntry: Equatable {
let level: Level
let message: String
}

// MARK: Private

private let lock = NSLock()
private var _entries: [LogEntry] = []

// MARK: Internal

var entries: [LogEntry] {
lock.withLock { _entries }
}

var infoMessages: [String] {
entries.filter { $0.level == .info }.map(\.message)
}

var warningMessages: [String] {
entries.filter { $0.level == .warning }.map(\.message)
}

var errorMessages: [String] {
entries.filter { $0.level == .error }.map(\.message)
}

// MARK: ILogger

func info(_ message: String) {
lock.withLock { _entries.append(.init(level: .info, message: message)) }
}

func warning(_ message: String) {
lock.withLock { _entries.append(.init(level: .warning, message: message)) }
}

func error(_ message: String) {
lock.withLock { _entries.append(.init(level: .error, message: message)) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
//
// Typhoon
// Copyright © 2026 Space Code. All rights reserved.
//

import Foundation
@testable import Typhoon
import XCTest

// MARK: - RetryPolicyServiceLoggerTests

final class RetryPolicyServiceLoggerTests: XCTestCase {
private var logger: MockLogger!

override func setUp() {
super.setUp()
logger = MockLogger()
}

override func tearDown() {
logger = nil
super.tearDown()
}

// MARK: - Tests

func test_logsNothing_onFirstAttemptSuccess() async throws {
// given
let sut = makeSUT()

// when
_ = try await sut.retry(strategy: nil, onFailure: nil) { 42 }

// then
XCTAssertTrue(logger.entries.isEmpty)
}

func test_logsWarning_onEachFailedAttempt() async {
// given
let sut = makeSUT(retry: 3)
let attempt = Counter()

// when
_ = try? await sut.retry(strategy: nil, onFailure: nil) {
attempt.increment()
if attempt.value < 3 { throw URLError(.notConnectedToInternet) }
return 42
}

// then
XCTAssertEqual(logger.warningMessages.count, 2)
XCTAssertTrue(logger.warningMessages.allSatisfy { $0.contains("[RetryPolicy]") })
}

func test_logsInfo_onSuccessAfterRetry() async throws {
let sut = makeSUT(retry: 3)
let attempt = Counter()

_ = try await sut.retry(strategy: nil, onFailure: nil) {
attempt.increment()
if attempt.value < 2 { throw URLError(.notConnectedToInternet) }
return 42
}

XCTAssertTrue(logger.infoMessages.contains { $0.contains("Succeeded after 2 attempt(s)") })
}

func test_logsError_onRetryLimitExceeded() async {
// given
let sut = makeSUT(retry: 2)

// when
_ = try? await sut.retry(strategy: nil, onFailure: nil) {
throw URLError(.notConnectedToInternet)
}

// then
XCTAssertTrue(logger.errorMessages.contains { $0.contains("Retry limit exceeded") })
}

func test_logsWarning_whenOnFailureStopsRetrying() async {
// given
let sut = makeSUT(retry: 5)

// when
_ = try? await sut.retry(
strategy: nil,
onFailure: { _ in false }
) {
throw URLError(.badServerResponse)
}

// then
XCTAssertTrue(logger.warningMessages.contains { $0.contains("onFailure returned false") })
}

func test_logsError_onTotalDurationExceeded() async {
// given
let sut = makeSUT(retry: 10, maxTotalDuration: .milliseconds(1))

// when
try? await Task.sleep(nanoseconds: 2_000_000)

_ = try? await sut.retry(strategy: nil, onFailure: nil) {
throw URLError(.timedOut)
}

// then
XCTAssertTrue(logger.errorMessages.contains { $0.contains("Total duration exceeded") })
}

func test_logsWarning_withFailedAttemptNumber() async {
// given
let sut = makeSUT(retry: 3)
let attempt = Counter()

// when
_ = try? await sut.retry(strategy: nil, onFailure: nil) {
attempt.increment()
throw URLError(.notConnectedToInternet)
}

// then
XCTAssertTrue(logger.warningMessages.first?.contains("Attempt 1") == true)
XCTAssertTrue(logger.warningMessages.dropFirst().first?.contains("Attempt 2") == true)
XCTAssertTrue(logger.warningMessages.dropFirst(2).first?.contains("Attempt 3") == true)
}
}

// MARK: - Helpers

private extension RetryPolicyServiceLoggerTests {
func makeSUT(
retry: UInt = 3,
maxTotalDuration: DispatchTimeInterval? = nil
) -> RetryPolicyService {
RetryPolicyService(
strategy: .constant(retry: retry, dispatchDuration: .milliseconds(1)),
maxTotalDuration: maxTotalDuration,
logger: logger
)
}
}
Loading