Skip to content

Commit cbf1f53

Browse files
committed
feat: implement logging functionality
1 parent b446363 commit cbf1f53

File tree

5 files changed

+338
-25
lines changed

5 files changed

+338
-25
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//
2+
// Typhoon
3+
// Copyright © 2026 Space Code. All rights reserved.
4+
//
5+
6+
#if canImport(OSLog)
7+
import OSLog
8+
9+
@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *)
10+
extension Logger: ILogger {
11+
public func info(_ message: String) {
12+
info("\(message, privacy: .public)")
13+
}
14+
15+
public func warning(_ message: String) {
16+
warning("\(message, privacy: .public)")
17+
}
18+
19+
public func error(_ message: String) {
20+
error("\(message, privacy: .public)")
21+
}
22+
}
23+
#endif
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//
2+
// Typhoon
3+
// Copyright © 2026 Space Code. All rights reserved.
4+
//
5+
6+
import Foundation
7+
8+
// MARK: - ILogger
9+
10+
/// A protocol that abstracts logging functionality.
11+
///
12+
/// Conform to this protocol to provide a custom logging implementation,
13+
/// or use the built-in `Logger` wrapper on Apple platforms.
14+
///
15+
/// ### Example
16+
/// ```swift
17+
/// struct PrintLogger: ILogger {
18+
/// func info(_ message: @autoclosure () -> String) {
19+
/// print("[INFO] \(message())")
20+
/// }
21+
/// func warning(_ message: @autoclosure () -> String) {
22+
/// print("[WARNING] \(message())")
23+
/// }
24+
/// func error(_ message: @autoclosure () -> String) {
25+
/// print("[ERROR] \(message())")
26+
/// }
27+
/// }
28+
/// ```
29+
public protocol ILogger: Sendable {
30+
/// Logs an informational message.
31+
/// - Parameter message: A closure that returns the message string (evaluated lazily).
32+
func info(_ message: String)
33+
34+
/// Logs a warning message.
35+
/// - Parameter message: A closure that returns the message string (evaluated lazily).
36+
func warning(_ message: String)
37+
38+
/// Logs an error message.
39+
/// - Parameter message: A closure that returns the message string (evaluated lazily).
40+
func error(_ message: String)
41+
}

Sources/Typhoon/Classes/RetryPolicyService/RetryPolicyService.swift

Lines changed: 72 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -59,18 +59,72 @@ public final class RetryPolicyService {
5959
/// Optional maximum total duration allowed for all retry attempts.
6060
private let maxTotalDuration: DispatchTimeInterval?
6161

62+
/// An optional logger used to record retry attempts and related events.
63+
private let logger: ILogger?
64+
6265
// MARK: Initialization
6366

6467
/// Initializes a new instance of `RetryPolicyService`.
6568
///
6669
/// - Parameters:
6770
/// - strategy: The strategy that determines how retries are performed.
6871
/// - maxTotalDuration: Optional maximum duration for all retries combined. If `nil`,
69-
/// retries can continue indefinitely based on the
70-
/// strategy.
71-
public init(strategy: RetryPolicyStrategy, maxTotalDuration: DispatchTimeInterval? = nil) {
72+
/// retries can continue indefinitely based on the strategy.
73+
/// - logger: An optional logger for capturing retry-related information.
74+
public init(
75+
strategy: RetryPolicyStrategy,
76+
maxTotalDuration: DispatchTimeInterval? = nil,
77+
logger: ILogger? = nil
78+
) {
7279
self.strategy = strategy
7380
self.maxTotalDuration = maxTotalDuration
81+
self.logger = logger
82+
}
83+
84+
// MARK: Private
85+
86+
private func calculateDeadline() -> Date? {
87+
maxTotalDuration?.nanoseconds.map {
88+
Date().addingTimeInterval(TimeInterval($0) / 1_000_000_000)
89+
}
90+
}
91+
92+
private func checkDeadline(_ deadline: Date?, attempt: Int) throws {
93+
if let deadline, Date() > deadline {
94+
logger?.error("[RetryPolicy] Total duration exceeded after \(attempt) attempt(s).")
95+
throw RetryPolicyError.totalDurationExceeded
96+
}
97+
}
98+
99+
private func handleRetryDecision(
100+
error: Error,
101+
onFailure: (@Sendable (Error) async -> Bool)?,
102+
iterator: inout some IteratorProtocol<UInt64>,
103+
attempt: Int
104+
) async throws {
105+
if let onFailure, await !onFailure(error) {
106+
logger?.warning("[RetryPolicy] Stopped retrying after \(attempt) attempt(s) — onFailure returned false.")
107+
throw error
108+
}
109+
110+
guard let duration = iterator.next() else {
111+
logger?.error("[RetryPolicy] Retry limit exceeded after \(attempt) attempt(s).")
112+
throw RetryPolicyError.retryLimitExceeded
113+
}
114+
115+
logger?.info("[RetryPolicy] Waiting \(duration)ns before attempt \(attempt + 1)...")
116+
try Task.checkCancellation()
117+
try await Task.sleep(nanoseconds: duration)
118+
}
119+
120+
private func logSuccess(attempt: Int) {
121+
if attempt > 0 {
122+
logger?.info("[RetryPolicy] Succeeded after \(attempt + 1) attempt(s).")
123+
}
124+
}
125+
126+
private func logFailure(attempt: Int, error: Error) {
127+
logger?.warning("[RetryPolicy] Attempt \(attempt) failed: \(error.localizedDescription).")
74128
}
75129
}
76130

@@ -91,34 +145,27 @@ extension RetryPolicyService: IRetryPolicyService {
91145
_ closure: @Sendable () async throws -> T
92146
) async throws -> T {
93147
let effectiveStrategy = strategy ?? self.strategy
94-
95148
var iterator = RetrySequence(strategy: effectiveStrategy).makeIterator()
96-
97-
let deadline = maxTotalDuration?.nanoseconds.map {
98-
Date().addingTimeInterval(TimeInterval($0) / 1_000_000_000)
99-
}
149+
let deadline = calculateDeadline()
150+
var attempt = 0
100151

101152
while true {
102-
if let deadline, Date() > deadline {
103-
throw RetryPolicyError.totalDurationExceeded
104-
}
153+
try checkDeadline(deadline, attempt: attempt)
105154

106155
do {
107-
return try await closure()
156+
let result = try await closure()
157+
logSuccess(attempt: attempt)
158+
return result
108159
} catch {
109-
let shouldContinue = await onFailure?(error) ?? true
110-
111-
if !shouldContinue {
112-
throw error
113-
}
114-
115-
guard let duration = iterator.next() else {
116-
throw RetryPolicyError.retryLimitExceeded
117-
}
118-
119-
try Task.checkCancellation()
120-
121-
try await Task.sleep(nanoseconds: duration)
160+
attempt += 1
161+
logFailure(attempt: attempt, error: error)
162+
163+
try await handleRetryDecision(
164+
error: error,
165+
onFailure: onFailure,
166+
iterator: &iterator,
167+
attempt: attempt
168+
)
122169
}
123170
}
124171
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
//
2+
// Typhoon
3+
// Copyright © 2026 Space Code. All rights reserved.
4+
//
5+
6+
import Foundation
7+
@testable import Typhoon
8+
9+
// MARK: - MockLogger
10+
11+
final class MockLogger: ILogger, @unchecked Sendable {
12+
// MARK: Types
13+
14+
enum Level {
15+
case info, warning, error
16+
}
17+
18+
struct LogEntry: Equatable {
19+
let level: Level
20+
let message: String
21+
}
22+
23+
// MARK: Private
24+
25+
private let lock = NSLock()
26+
private var _entries: [LogEntry] = []
27+
28+
// MARK: Internal
29+
30+
var entries: [LogEntry] {
31+
lock.withLock { _entries }
32+
}
33+
34+
var infoMessages: [String] {
35+
entries.filter { $0.level == .info }.map(\.message)
36+
}
37+
38+
var warningMessages: [String] {
39+
entries.filter { $0.level == .warning }.map(\.message)
40+
}
41+
42+
var errorMessages: [String] {
43+
entries.filter { $0.level == .error }.map(\.message)
44+
}
45+
46+
// MARK: ILogger
47+
48+
func info(_ message: String) {
49+
lock.withLock { _entries.append(.init(level: .info, message: message)) }
50+
}
51+
52+
func warning(_ message: String) {
53+
lock.withLock { _entries.append(.init(level: .warning, message: message)) }
54+
}
55+
56+
func error(_ message: String) {
57+
lock.withLock { _entries.append(.init(level: .error, message: message)) }
58+
}
59+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
//
2+
// Typhoon
3+
// Copyright © 2026 Space Code. All rights reserved.
4+
//
5+
6+
import Foundation
7+
@testable import Typhoon
8+
import XCTest
9+
10+
// MARK: - RetryPolicyServiceLoggerTests
11+
12+
final class RetryPolicyServiceLoggerTests: XCTestCase {
13+
private var logger: MockLogger!
14+
15+
override func setUp() {
16+
super.setUp()
17+
logger = MockLogger()
18+
}
19+
20+
override func tearDown() {
21+
logger = nil
22+
super.tearDown()
23+
}
24+
25+
// MARK: - Tests
26+
27+
func test_logsNothing_onFirstAttemptSuccess() async throws {
28+
// given
29+
let sut = makeSUT()
30+
31+
// when
32+
_ = try await sut.retry(strategy: nil, onFailure: nil) { 42 }
33+
34+
// then
35+
XCTAssertTrue(logger.entries.isEmpty)
36+
}
37+
38+
func test_logsWarning_onEachFailedAttempt() async {
39+
// given
40+
let sut = makeSUT(retry: 3)
41+
let attempt = Counter()
42+
43+
// when
44+
_ = try? await sut.retry(strategy: nil, onFailure: nil) {
45+
attempt.increment()
46+
if attempt.value < 3 { throw URLError(.notConnectedToInternet) }
47+
return 42
48+
}
49+
50+
// then
51+
XCTAssertEqual(logger.warningMessages.count, 2)
52+
XCTAssertTrue(logger.warningMessages.allSatisfy { $0.contains("[RetryPolicy]") })
53+
}
54+
55+
func test_logsInfo_onSuccessAfterRetry() async throws {
56+
let sut = makeSUT(retry: 3)
57+
let attempt = Counter()
58+
59+
_ = try await sut.retry(strategy: nil, onFailure: nil) {
60+
attempt.increment()
61+
if attempt.value < 2 { throw URLError(.notConnectedToInternet) }
62+
return 42
63+
}
64+
65+
XCTAssertTrue(logger.infoMessages.contains { $0.contains("Succeeded after 2 attempt(s)") })
66+
}
67+
68+
func test_logsError_onRetryLimitExceeded() async {
69+
// given
70+
let sut = makeSUT(retry: 2)
71+
72+
// when
73+
_ = try? await sut.retry(strategy: nil, onFailure: nil) {
74+
throw URLError(.notConnectedToInternet)
75+
}
76+
77+
// then
78+
XCTAssertTrue(logger.errorMessages.contains { $0.contains("Retry limit exceeded") })
79+
}
80+
81+
func test_logsWarning_whenOnFailureStopsRetrying() async {
82+
// given
83+
let sut = makeSUT(retry: 5)
84+
85+
// when
86+
_ = try? await sut.retry(
87+
strategy: nil,
88+
onFailure: { _ in false }
89+
) {
90+
throw URLError(.badServerResponse)
91+
}
92+
93+
// then
94+
XCTAssertTrue(logger.warningMessages.contains { $0.contains("onFailure returned false") })
95+
}
96+
97+
func test_logsError_onTotalDurationExceeded() async {
98+
// given
99+
let sut = makeSUT(retry: 10, maxTotalDuration: .milliseconds(1))
100+
101+
// when
102+
try? await Task.sleep(nanoseconds: 2_000_000)
103+
104+
_ = try? await sut.retry(strategy: nil, onFailure: nil) {
105+
throw URLError(.timedOut)
106+
}
107+
108+
// then
109+
XCTAssertTrue(logger.errorMessages.contains { $0.contains("Total duration exceeded") })
110+
}
111+
112+
func test_logsWarning_withFailedAttemptNumber() async {
113+
// given
114+
let sut = makeSUT(retry: 3)
115+
let attempt = Counter()
116+
117+
// when
118+
_ = try? await sut.retry(strategy: nil, onFailure: nil) {
119+
attempt.increment()
120+
throw URLError(.notConnectedToInternet)
121+
}
122+
123+
// then
124+
XCTAssertTrue(logger.warningMessages.first?.contains("Attempt 1") == true)
125+
XCTAssertTrue(logger.warningMessages.dropFirst().first?.contains("Attempt 2") == true)
126+
XCTAssertTrue(logger.warningMessages.dropFirst(2).first?.contains("Attempt 3") == true)
127+
}
128+
}
129+
130+
// MARK: - Helpers
131+
132+
private extension RetryPolicyServiceLoggerTests {
133+
func makeSUT(
134+
retry: UInt = 3,
135+
maxTotalDuration: DispatchTimeInterval? = nil
136+
) -> RetryPolicyService {
137+
RetryPolicyService(
138+
strategy: .constant(retry: retry, dispatchDuration: .milliseconds(1)),
139+
maxTotalDuration: maxTotalDuration,
140+
logger: logger
141+
)
142+
}
143+
}

0 commit comments

Comments
 (0)