From cbf1f537c4d3a9934f1d992a9d1d5c8ea687672f Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Fri, 6 Mar 2026 16:35:10 +0400 Subject: [PATCH] feat: implement logging functionality --- .../Classes/Extensions/Logger+ILogger.swift | 23 +++ Sources/Typhoon/Classes/Logger/ILogger.swift | 41 +++++ .../RetryPolicyService.swift | 97 +++++++++--- Tests/TyphoonTests/Mocks/MockLogger.swift | 59 ++++++++ .../RetryPolicyServiceLoggerTests.swift | 143 ++++++++++++++++++ 5 files changed, 338 insertions(+), 25 deletions(-) create mode 100644 Sources/Typhoon/Classes/Extensions/Logger+ILogger.swift create mode 100644 Sources/Typhoon/Classes/Logger/ILogger.swift create mode 100644 Tests/TyphoonTests/Mocks/MockLogger.swift create mode 100644 Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceLoggerTests.swift diff --git a/Sources/Typhoon/Classes/Extensions/Logger+ILogger.swift b/Sources/Typhoon/Classes/Extensions/Logger+ILogger.swift new file mode 100644 index 0000000..5c48ac7 --- /dev/null +++ b/Sources/Typhoon/Classes/Extensions/Logger+ILogger.swift @@ -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 diff --git a/Sources/Typhoon/Classes/Logger/ILogger.swift b/Sources/Typhoon/Classes/Logger/ILogger.swift new file mode 100644 index 0000000..d871c08 --- /dev/null +++ b/Sources/Typhoon/Classes/Logger/ILogger.swift @@ -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) +} diff --git a/Sources/Typhoon/Classes/RetryPolicyService/RetryPolicyService.swift b/Sources/Typhoon/Classes/RetryPolicyService/RetryPolicyService.swift index 87319f3..539aaee 100644 --- a/Sources/Typhoon/Classes/RetryPolicyService/RetryPolicyService.swift +++ b/Sources/Typhoon/Classes/RetryPolicyService/RetryPolicyService.swift @@ -59,6 +59,9 @@ 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`. @@ -66,11 +69,62 @@ public final class 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, + 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).") } } @@ -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 + ) } } } diff --git a/Tests/TyphoonTests/Mocks/MockLogger.swift b/Tests/TyphoonTests/Mocks/MockLogger.swift new file mode 100644 index 0000000..29b7713 --- /dev/null +++ b/Tests/TyphoonTests/Mocks/MockLogger.swift @@ -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)) } + } +} diff --git a/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceLoggerTests.swift b/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceLoggerTests.swift new file mode 100644 index 0000000..de5693c --- /dev/null +++ b/Tests/TyphoonTests/UnitTests/RetryService/RetryPolicyServiceLoggerTests.swift @@ -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 + ) + } +}