From e15527efd0b214324dee1ee25941846e77aafdce Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:30:40 +0900 Subject: [PATCH 1/7] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=A0=9C=EB=AF=B8=EB=82=98=EC=9D=B4=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gemini/styleguide.md | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 .gemini/styleguide.md diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md deleted file mode 100644 index d4de7ea..0000000 --- a/.gemini/styleguide.md +++ /dev/null @@ -1,9 +0,0 @@ -## Review Guidelines - -- Write all review comments in Korean. -- Keep review comments concise and high-signal. -- Prioritize findings about bugs, performance, and readability. -- Do not explain obvious, trivial, or low-signal issues. -- When useful, begin the review with a short summary of the main changes. -- Focus on actionable feedback rather than broad commentary. - From bea175c9d047a64da20ed12806c569a028345dc9 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:32:44 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EA=B3=84=EC=95=BD=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/Nexa/Core/NXHTTPMethod.swift | 2 +- Sources/Nexa/Core/NXRetryPolicy.swift | 35 +++++++++++++++++++- Tests/NexaTests/NXCoreModelPolicyTests.swift | 16 ++++++++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/Sources/Nexa/Core/NXHTTPMethod.swift b/Sources/Nexa/Core/NXHTTPMethod.swift index dd1741a..673c224 100644 --- a/Sources/Nexa/Core/NXHTTPMethod.swift +++ b/Sources/Nexa/Core/NXHTTPMethod.swift @@ -8,7 +8,7 @@ import Foundation /// HTTP methods supported by Nexa request builders. -public enum NXHTTPMethod: String, Sendable { +public enum NXHTTPMethod: String, Sendable, Hashable { case get = "GET" case post = "POST" case put = "PUT" diff --git a/Sources/Nexa/Core/NXRetryPolicy.swift b/Sources/Nexa/Core/NXRetryPolicy.swift index bd7b451..7a023e9 100644 --- a/Sources/Nexa/Core/NXRetryPolicy.swift +++ b/Sources/Nexa/Core/NXRetryPolicy.swift @@ -8,6 +8,16 @@ import Foundation /// Retry behavior applied to a request when execution fails. +/// +/// ## Overview +/// +/// By default, Nexa retries `GET`, `HEAD`, `PUT`, `DELETE`, and `OPTIONS` requests +/// for retryable transport errors and configured status codes. Add `POST` or `PATCH` +/// to ``retryableMethods`` only when the endpoint safely accepts repeated requests. +/// +/// For `429` and `503` responses, a valid `Retry-After` value takes precedence over +/// local backoff and is limited by ``maximumServerDelay``. ``Jitter`` applies only to +/// local backoff delays. public struct NXRetryPolicy: Sendable { /// Delay strategy used between retry attempts. public enum Backoff: Sendable { @@ -28,12 +38,26 @@ public struct NXRetryPolicy: Sendable { } } + /// Randomization applied to local backoff delays. + public enum Jitter: Sendable, Equatable { + /// Keeps the local backoff delay unchanged. + case none + /// Uses a random value within the local backoff delay range. + case full + } + /// Maximum number of attempts including the initial request. public var maxAttempts: Int /// Delay strategy used between retry attempts. public var backoff: Backoff /// HTTP status codes that are eligible for retry. public var retryableStatusCodes: Set + /// HTTP methods that are eligible for retry. + public var retryableMethods: Set + /// Upper limit applied to a server-provided retry delay. + public var maximumServerDelay: TimeInterval + /// Randomization applied to local backoff delays. + public var jitter: Jitter /// Creates a retry policy. /// @@ -41,13 +65,22 @@ public struct NXRetryPolicy: Sendable { /// - maxAttempts: Maximum number of attempts including the initial request. /// - backoff: Delay strategy used between retry attempts. /// - retryableStatusCodes: Status codes that should trigger a retry. + /// - retryableMethods: HTTP methods that should trigger a retry. + /// - maximumServerDelay: Upper limit applied to a server-provided retry delay. + /// - jitter: Randomization applied to local backoff delays. public init( maxAttempts: Int, backoff: Backoff = .fixed(0), - retryableStatusCodes: Set = [408, 429, 500, 502, 503, 504] + retryableStatusCodes: Set = [408, 429, 500, 502, 503, 504], + retryableMethods: Set = [.get, .head, .put, .delete, .options], + maximumServerDelay: TimeInterval = 60, + jitter: Jitter = .none ) { self.maxAttempts = max(1, maxAttempts) self.backoff = backoff self.retryableStatusCodes = retryableStatusCodes + self.retryableMethods = retryableMethods + self.maximumServerDelay = max(0, maximumServerDelay) + self.jitter = jitter } } diff --git a/Tests/NexaTests/NXCoreModelPolicyTests.swift b/Tests/NexaTests/NXCoreModelPolicyTests.swift index 54e07ba..a793bf6 100644 --- a/Tests/NexaTests/NXCoreModelPolicyTests.swift +++ b/Tests/NexaTests/NXCoreModelPolicyTests.swift @@ -60,6 +60,17 @@ struct NXCoreModelPolicyTests { #expect(exponentialDelay.delay(forAttempt: 4) == 2.0) } + @Test("재시도 정책이 멱등 method와 서버 지연 기본값을 제공한다") + func retryPolicyDefaultRetrySemantics() { + let defaultRetryPolicy = NXRetryPolicy(maxAttempts: 2) + let retryPolicy = NXRetryPolicy(maxAttempts: 2, maximumServerDelay: -1) + + #expect(defaultRetryPolicy.retryableMethods == [.get, .head, .put, .delete, .options]) + #expect(defaultRetryPolicy.maximumServerDelay == 60) + #expect(defaultRetryPolicy.jitter == .none) + #expect(retryPolicy.maximumServerDelay == 0) + } + @Test("응답 검증 정책이 상태코드 허용 규칙을 적용한다") func validationPolicyAllowsExpectedStatusCodes() { #expect(NXValidationPolicy.none.allows(statusCode: 404) == true) @@ -95,7 +106,10 @@ struct NXCoreModelPolicyTests { } #expect(capturedError.code == .timedOut) - guard case let .invalidStatus(statusCode, data) = NXError.invalidStatus(statusCode: 500, data: Data("x".utf8)) else { + guard case let .invalidStatus(statusCode, data) = NXError.invalidStatus( + statusCode: 500, + data: Data("x".utf8) + ) else { Issue.record("invalidStatus case mapping failed") return } From 27ee5e18ba4967ce9b5d15ad3e123f2283254c30 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:33:20 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EB=A9=B1=EB=93=B1=20method=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EA=B8=B0=EC=A4=80=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/Nexa/Runtime/NXRetryInterceptor.swift | 4 ++ Tests/NexaTests/NXRetryInterceptorTests.swift | 71 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 Tests/NexaTests/NXRetryInterceptorTests.swift diff --git a/Sources/Nexa/Runtime/NXRetryInterceptor.swift b/Sources/Nexa/Runtime/NXRetryInterceptor.swift index 59a93c1..a69be5d 100644 --- a/Sources/Nexa/Runtime/NXRetryInterceptor.swift +++ b/Sources/Nexa/Runtime/NXRetryInterceptor.swift @@ -16,6 +16,10 @@ struct NXRetryInterceptor: NXHTTPInterceptor { return try await next(context) } + guard retryPolicy.retryableMethods.contains(context.specification.method) else { + return try await next(context) + } + for attemptNumber in 1.. Int { + let counter = AttemptCounter() + var request = URLRequest(url: URL(string: "https://example.com/users")!) + request.httpMethod = method.rawValue + var specification = RequestSpec(method: method, path: "/users") + specification.retryPolicy = policy + let configuration = NXClientConfiguration( + baseURL: URL(string: "https://example.com")! + ) + let context = NXRequestExecutionContext( + request: request, + requestIdentifier: specification.requestIdentifier, + attemptNumber: 1, + userInfo: specification.userInfo, + specification: specification, + clientConfiguration: configuration + ) + + do { + _ = try await NXRetryInterceptor().intercept(context: context) { _ in + _ = await counter.increment() + throw URLError(.timedOut) + } + } catch { + return await counter.value() + } + + return await counter.value() + } + +} From d98f4777350a81e06c958fd27a66a2c92ff7c976 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:33:50 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20Retry-After=20=EC=A7=80=EC=97=B0=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NXRetryExecutionDependencies.swift | 26 +++ Sources/Nexa/Runtime/NXRetryInterceptor.swift | 217 ++++++++++++++++-- .../Fixtures/NXNetworkTestSupport.swift | 13 +- .../NexaTests/NXRetryAfterParsingTests.swift | 206 +++++++++++++++++ Tests/NexaTests/NXRetryDelayTests.swift | 156 +++++++++++++ 5 files changed, 592 insertions(+), 26 deletions(-) create mode 100644 Sources/Nexa/Runtime/NXRetryExecutionDependencies.swift create mode 100644 Tests/NexaTests/NXRetryAfterParsingTests.swift create mode 100644 Tests/NexaTests/NXRetryDelayTests.swift diff --git a/Sources/Nexa/Runtime/NXRetryExecutionDependencies.swift b/Sources/Nexa/Runtime/NXRetryExecutionDependencies.swift new file mode 100644 index 0000000..bd3b7d3 --- /dev/null +++ b/Sources/Nexa/Runtime/NXRetryExecutionDependencies.swift @@ -0,0 +1,26 @@ +// +// NXRetryExecutionDependencies.swift +// Nexa +// +// Created by opfic on 8/22/26. +// + +import Foundation + +struct NXRetryExecutionDependencies: Sendable { + let now: @Sendable () -> Date + let sleep: @Sendable (TimeInterval) async throws -> Void + let randomUnit: @Sendable () -> Double + + static let live = Self( + now: { Date() }, + sleep: { delay in + guard 0 < delay else { + return + } + + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + }, + randomUnit: { Double.random(in: 0...1) } + ) +} diff --git a/Sources/Nexa/Runtime/NXRetryInterceptor.swift b/Sources/Nexa/Runtime/NXRetryInterceptor.swift index a69be5d..9b44e9a 100644 --- a/Sources/Nexa/Runtime/NXRetryInterceptor.swift +++ b/Sources/Nexa/Runtime/NXRetryInterceptor.swift @@ -8,6 +8,12 @@ import Foundation struct NXRetryInterceptor: NXHTTPInterceptor { + private let dependencies: NXRetryExecutionDependencies + + init(dependencies: NXRetryExecutionDependencies = .live) { + self.dependencies = dependencies + } + func intercept( context: NXRequestExecutionContext, next: @escaping @Sendable (NXRequestExecutionContext) async throws -> NXRawResponse @@ -22,6 +28,7 @@ struct NXRetryInterceptor: NXHTTPInterceptor { for attemptNumber in 1.. TimeInterval { + if let serverDelay = serverDelay(from: response.response) { + return min(serverDelay, retryPolicy.maximumServerDelay) + } + + return localDelay(retryPolicy: retryPolicy, attemptNumber: attemptNumber) + } + + private func serverDelay(from response: HTTPURLResponse) -> TimeInterval? { + guard response.statusCode == 429 || response.statusCode == 503, + let value = response.value(forHTTPHeaderField: "Retry-After") else { + return nil + } + + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + + if let seconds = delaySeconds(from: trimmedValue) { + return seconds + } + + guard let date = httpDate(from: trimmedValue) else { + return nil + } + + return max(0, date.timeIntervalSince(dependencies.now())) + } + + private func delaySeconds(from value: String) -> TimeInterval? { + guard !value.isEmpty, + value.utf8.allSatisfy({ 48 <= $0 && $0 <= 57 }) else { + return nil + } + + return value.utf8.reduce(into: 0) { seconds, character in + let digit = TimeInterval(character - 48) + + if (TimeInterval.greatestFiniteMagnitude - digit) / 10 < seconds { + seconds = .greatestFiniteMagnitude + return + } + + seconds = seconds * 10 + digit } + } + + private func httpDate(from value: String) -> Date? { + if let date = formattedDate( + from: value, + format: "EEE',' dd MMM yyyy HH':'mm':'ss 'GMT'" + ) { + return date + } + + if let date = rfc850Date(from: value) { + return date + } + + return formattedDate( + from: value, + format: "EEE MMM d HH':'mm':'ss yyyy" + ) ?? formattedDate( + from: value, + format: "EEE MMM dd HH':'mm':'ss yyyy" + ) + } - try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + private func rfc850Date(from value: String) -> Date? { + let formatter = httpDateFormatter( + format: "EEEE',' dd-MMM-yy HH':'mm':'ss 'GMT'" + ) + + guard let parsedDate = formatter.date(from: value) else { + return nil + } + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + var components = calendar.dateComponents( + [.year, .month, .day, .hour, .minute, .second], + from: parsedDate + ) + + guard let year = components.year else { + return nil + } + + components.year = 2_000 + year % 100 + + guard let futureDate = calendar.date(from: components), + let futureLimit = calendar.date( + byAdding: .year, + value: 50, + to: dependencies.now() + ) else { + return nil + } + + let date: Date + + if futureLimit < futureDate { + guard let pastDate = calendar.date(byAdding: .year, value: -100, to: futureDate) else { + return nil + } + date = pastDate + } else { + date = futureDate + } + + guard formatter.string(from: date) == value else { + return nil + } + + return date + } + + private func formattedDate(from value: String, format: String) -> Date? { + let formatter = httpDateFormatter(format: format) + + guard let date = formatter.date(from: value), + formatter.string(from: date) == value else { + return nil + } + + return date + } + + private func httpDateFormatter(format: String) -> DateFormatter { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.isLenient = false + formatter.dateFormat = format + return formatter + } + + private func localDelay( + retryPolicy: NXRetryPolicy, + attemptNumber: Int + ) -> TimeInterval { + let delay = retryPolicy.backoff.delay(forAttempt: attemptNumber) + + switch retryPolicy.jitter { + case .none: + return delay + case .full: + return delay * min(max(0, dependencies.randomUnit()), 1) + } + } + + private func scheduleRetry( + context: NXRequestExecutionContext, + nextAttemptNumber: Int, + delay: TimeInterval + ) async throws { + try Task.checkCancellation() + await context.clientConfiguration.logger.log( + .retry( + NXRetryLog( + requestIdentifier: context.requestIdentifier, + nextAttemptNumber: nextAttemptNumber, + delay: delay + ) + ) + ) + try Task.checkCancellation() + try await dependencies.sleep(delay) + try Task.checkCancellation() } } diff --git a/Tests/NexaTests/Fixtures/NXNetworkTestSupport.swift b/Tests/NexaTests/Fixtures/NXNetworkTestSupport.swift index 6ad0f94..67c798f 100644 --- a/Tests/NexaTests/Fixtures/NXNetworkTestSupport.swift +++ b/Tests/NexaTests/Fixtures/NXNetworkTestSupport.swift @@ -100,6 +100,10 @@ actor MemoryLogger: NXLogger { } } + func allEvents() -> [NXLogEvent] { + events + } + func authRefreshLogs() -> [NXAuthRefreshLog] { events.compactMap { event in guard case let .authRefresh(log) = event else { @@ -110,12 +114,17 @@ actor MemoryLogger: NXLogger { } } -func makeRawResponse(statusCode: Int, body: String, path: String = "/") -> NXRawResponse { +func makeRawResponse( + statusCode: Int, + body: String, + path: String = "/", + headers: [String: String]? = nil +) -> NXRawResponse { let response = HTTPURLResponse( url: URL(string: "https://example.com\(path)")!, statusCode: statusCode, httpVersion: nil, - headerFields: nil + headerFields: headers )! return NXRawResponse(data: Data(body.utf8), response: response) } diff --git a/Tests/NexaTests/NXRetryAfterParsingTests.swift b/Tests/NexaTests/NXRetryAfterParsingTests.swift new file mode 100644 index 0000000..f9db286 --- /dev/null +++ b/Tests/NexaTests/NXRetryAfterParsingTests.swift @@ -0,0 +1,206 @@ +// +// NXRetryAfterParsingTests.swift +// Nexa +// +// Created by opfic on 8/22/26. +// + +import Foundation +import Testing +@testable import Nexa + +@Suite("Retry-After HTTP-date 해석 테스트") +struct NXRetryAfterParsingTests { + @Test("RFC 850 두 자리 연도는 현재 시각 기준 50년 규칙을 적용한다") + func rfc850DateUsesHTTPDateYearRule() async throws { + let calendar = Calendar(identifier: .gregorian) + let referenceDate = calendar.date( + from: DateComponents(year: 2026, month: 1, day: 1) + )! + let serverDate = calendar.date( + from: DateComponents(year: 2060, month: 11, day: 6, hour: 8, minute: 49, second: 37) + )! + let policy = NXRetryPolicy( + maxAttempts: 2, + maximumServerDelay: .greatestFiniteMagnitude + ) + + let delay = try await retryDelay( + header: rfc850DateString(from: serverDate), + policy: policy, + now: referenceDate + ) + + #expect(delay == serverDate.timeIntervalSince(referenceDate)) + } + + @Test("GMT가 아닌 Retry-After 시간대는 local backoff를 사용한다") + func unsupportedTimezoneUsesLocalBackoff() async throws { + let delay = try await retryDelay( + header: "Thu, 01 Jan 1970 00:02:00 PST", + policy: NXRetryPolicy(maxAttempts: 2, backoff: .fixed(4)), + now: Date.distantPast + ) + + #expect(delay == 4) + } + + @Test("과거 HTTP-date는 0초 지연을 사용한다") + func pastHTTPDateUsesZeroDelay() async throws { + let delay = try await retryDelay( + header: "Thu, 01 Jan 1970 00:00:00 GMT", + policy: NXRetryPolicy(maxAttempts: 2), + now: Date(timeIntervalSince1970: 1) + ) + + #expect(delay == 0) + } + + @Test("음수 Retry-After는 local backoff를 사용한다") + func negativeRetryAfterUsesLocalBackoff() async throws { + let delay = try await retryDelay( + header: "-1", + policy: NXRetryPolicy(maxAttempts: 2, backoff: .fixed(4)), + now: Date.distantPast + ) + + #expect(delay == 4) + } + + @Test("부호가 있는 Retry-After는 local backoff를 사용한다") + func signedRetryAfterUsesLocalBackoff() async throws { + let delay = try await retryDelay( + header: "+1", + policy: NXRetryPolicy(maxAttempts: 2, backoff: .fixed(4)), + now: Date.distantPast + ) + + #expect(delay == 4) + } + + @Test("큰 Retry-After 숫자는 서버 지연 상한을 사용한다") + func overflowingRetryAfterUsesServerDelayCap() async throws { + let delay = try await retryDelay( + header: String(repeating: "9", count: 100), + policy: NXRetryPolicy(maxAttempts: 2, maximumServerDelay: 60), + now: Date.distantPast + ) + + #expect(delay == 60) + } + + @Test("429와 503 외 상태 코드의 Retry-After는 local backoff를 사용한다") + func retryAfterOutsideSupportedStatusUsesLocalBackoff() async throws { + let delay = try await retryDelay( + header: "120", + policy: NXRetryPolicy(maxAttempts: 2, backoff: .fixed(4)), + now: Date.distantPast, + statusCode: 500 + ) + + #expect(delay == 4) + } + + @Test("재시도 불가 상태의 Retry-After는 추가 attempt를 만들지 않는다") + func nonRetryableStatusDoesNotRetryWithRetryAfter() async throws { + let recorder = RetryAfterRecorder() + let logger = MemoryLogger() + let counter = AttemptCounter() + let dependencies = await recorder.dependencies(now: Date.distantPast) + let request = URLRequest(url: URL(string: "https://example.com/users")!) + var specification = RequestSpec(method: .get, path: "/users") + specification.retryPolicy = NXRetryPolicy(maxAttempts: 2, backoff: .fixed(4)) + let context = NXRequestExecutionContext( + request: request, + requestIdentifier: specification.requestIdentifier, + attemptNumber: 1, + userInfo: specification.userInfo, + specification: specification, + clientConfiguration: NXClientConfiguration( + baseURL: URL(string: "https://example.com")!, + logger: logger + ) + ) + + let response = try await NXRetryInterceptor(dependencies: dependencies).intercept(context: context) { _ in + _ = await counter.increment() + return makeRawResponse( + statusCode: 400, + body: "{}", + headers: ["Retry-After": "120"] + ) + } + + #expect(response.response.statusCode == 400) + #expect(await counter.value() == 1) + #expect(await recorder.delays().isEmpty) + #expect(await logger.retryLogs().isEmpty) + } + + private func retryDelay( + header: String, + policy: NXRetryPolicy, + now: Date, + statusCode: Int = 429 + ) async throws -> TimeInterval { + let recorder = RetryAfterRecorder() + let dependencies = await recorder.dependencies(now: now) + let request = URLRequest(url: URL(string: "https://example.com/users")!) + var specification = RequestSpec(method: .get, path: "/users") + specification.retryPolicy = policy + let context = NXRequestExecutionContext( + request: request, + requestIdentifier: specification.requestIdentifier, + attemptNumber: 1, + userInfo: specification.userInfo, + specification: specification, + clientConfiguration: NXClientConfiguration( + baseURL: URL(string: "https://example.com")! + ) + ) + + _ = try await NXRetryInterceptor(dependencies: dependencies).intercept(context: context) { context in + if context.attemptNumber == 1 { + return makeRawResponse( + statusCode: statusCode, + body: "{}", + headers: ["Retry-After": header] + ) + } + + return makeRawResponse(statusCode: 200, body: "{}") + } + + return await recorder.delays()[0] + } + + private func rfc850DateString(from date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "EEEE',' dd-MMM-yy HH':'mm':'ss 'GMT'" + return formatter.string(from: date) + } +} + +private actor RetryAfterRecorder { + private var recordedDelays: [TimeInterval] = [] + + func dependencies(now: Date) -> NXRetryExecutionDependencies { + NXRetryExecutionDependencies( + now: { now }, + sleep: { delay in + await self.record(delay: delay) + }, + randomUnit: { 0 } + ) + } + + func delays() -> [TimeInterval] { + recordedDelays + } + + private func record(delay: TimeInterval) { + recordedDelays.append(delay) + } +} diff --git a/Tests/NexaTests/NXRetryDelayTests.swift b/Tests/NexaTests/NXRetryDelayTests.swift new file mode 100644 index 0000000..711c7a0 --- /dev/null +++ b/Tests/NexaTests/NXRetryDelayTests.swift @@ -0,0 +1,156 @@ +// +// NXRetryDelayTests.swift +// Nexa +// +// Created by opfic on 8/22/26. +// + +import Foundation +import Testing +@testable import Nexa + +@Suite("재시도 지연 테스트") +struct NXRetryDelayTests { + @Test("Retry-After 초 단위는 서버 지연 상한을 적용한다", arguments: [429, 503]) + func retryAfterSecondsUsesServerDelayCap(statusCode: Int) async throws { + let recorder = RetryExecutionRecorder() + let logger = MemoryLogger() + let policy = NXRetryPolicy( + maxAttempts: 2, + maximumServerDelay: 60, + jitter: .full + ) + + _ = try await execute( + policy: policy, + responses: [ + makeRawResponse( + statusCode: statusCode, + body: "{}", + headers: ["Retry-After": "120"] + ), + makeRawResponse(statusCode: 200, body: "{}") + ], + logger: logger, + dependencies: await recorder.dependencies(now: Date.distantPast, randomUnit: 0) + ) + + #expect(await recorder.delays() == [60]) + #expect(await logger.retryLogs().map(\.delay) == [60]) + } + + @Test("Retry-After HTTP-date는 주입된 현재 시각을 기준으로 계산한다") + func retryAfterHTTPDateUsesInjectedCurrentTime() async throws { + let recorder = RetryExecutionRecorder() + let policy = NXRetryPolicy(maxAttempts: 2, maximumServerDelay: 180) + let referenceDate = Date(timeIntervalSince1970: 0) + + _ = try await execute( + policy: policy, + responses: [ + makeRawResponse( + statusCode: 429, + body: "{}", + headers: ["Retry-After": "Thu, 01 Jan 1970 00:02:00 GMT"] + ), + makeRawResponse(statusCode: 200, body: "{}") + ], + dependencies: await recorder.dependencies(now: referenceDate, randomUnit: 0) + ) + + #expect(await recorder.delays() == [120]) + } + + @Test("잘못된 Retry-After는 local backoff로 복귀한다") + func invalidRetryAfterUsesLocalBackoff() async throws { + let recorder = RetryExecutionRecorder() + let policy = NXRetryPolicy(maxAttempts: 2, backoff: .fixed(4)) + + _ = try await execute( + policy: policy, + responses: [ + makeRawResponse( + statusCode: 429, + body: "{}", + headers: ["Retry-After": "later"] + ), + makeRawResponse(statusCode: 200, body: "{}") + ], + dependencies: await recorder.dependencies(now: Date.distantPast, randomUnit: 0) + ) + + #expect(await recorder.delays() == [4]) + } + + @Test("local jitter는 주입된 무작위 값으로 계산한다") + func localJitterUsesInjectedRandomValue() async throws { + let recorder = RetryExecutionRecorder() + let policy = NXRetryPolicy(maxAttempts: 2, backoff: .fixed(4), jitter: .full) + + _ = try await execute( + policy: policy, + responses: [ + makeRawResponse( + statusCode: 500, + body: "{}", + headers: ["Retry-After": "120"] + ), + makeRawResponse(statusCode: 200, body: "{}") + ], + dependencies: await recorder.dependencies(now: Date.distantPast, randomUnit: 0.25) + ) + + #expect(await recorder.delays() == [1]) + } + + private func execute( + policy: NXRetryPolicy, + responses: [NXRawResponse], + logger: any NXLogger = NXNoopLogger(), + dependencies: NXRetryExecutionDependencies + ) async throws -> NXRawResponse { + let counter = AttemptCounter() + let request = URLRequest(url: URL(string: "https://example.com/users")!) + var specification = RequestSpec(method: .get, path: "/users") + specification.retryPolicy = policy + let configuration = NXClientConfiguration( + baseURL: URL(string: "https://example.com")!, + logger: logger + ) + let context = NXRequestExecutionContext( + request: request, + requestIdentifier: specification.requestIdentifier, + attemptNumber: 1, + userInfo: specification.userInfo, + specification: specification, + clientConfiguration: configuration + ) + + return try await NXRetryInterceptor(dependencies: dependencies).intercept(context: context) { _ in + let attemptNumber = await counter.increment() + return responses[attemptNumber - 1] + } + } +} + +private actor RetryExecutionRecorder { + private var recordedDelays: [TimeInterval] = [] + + func dependencies(now: Date, randomUnit: Double) -> NXRetryExecutionDependencies { + NXRetryExecutionDependencies( + now: { now }, + sleep: { delay in + await self.record(delay: delay) + }, + randomUnit: { randomUnit } + ) + } + + func delays() -> [TimeInterval] { + recordedDelays + } + + private func record(delay: TimeInterval) { + recordedDelays.append(delay) + } +} From 2125202d7ec66de76ad1a7e86e30a66277d96915 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:34:01 +0900 Subject: [PATCH 5/7] =?UTF-8?q?test:=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=EC=99=80=20logger=20=EC=88=9C=EC=84=9C=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/Nexa/Runtime/NXRequestExecutor.swift | 11 +- Tests/NexaTests/NXRetryExecutionTests.swift | 214 +++++++++++++++++++ 2 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 Tests/NexaTests/NXRetryExecutionTests.swift diff --git a/Sources/Nexa/Runtime/NXRequestExecutor.swift b/Sources/Nexa/Runtime/NXRequestExecutor.swift index dc892cd..539601b 100644 --- a/Sources/Nexa/Runtime/NXRequestExecutor.swift +++ b/Sources/Nexa/Runtime/NXRequestExecutor.swift @@ -11,7 +11,8 @@ enum NXRequestExecutor { static func executeRaw( clientConfiguration: NXClientConfiguration, responseCacheStore: NXResponseCacheStore?, - requestSpec: RequestSpec + requestSpec: RequestSpec, + retryExecutionDependencies: NXRetryExecutionDependencies = .live ) async throws -> NXRawResponse { do { let request = try NXRequestAssembler.assemble( @@ -31,7 +32,8 @@ enum NXRequestExecutor { interceptors: runtimeInterceptors( clientConfiguration: clientConfiguration, responseCacheStore: responseCacheStore, - requestSpec: requestSpec + requestSpec: requestSpec, + retryExecutionDependencies: retryExecutionDependencies ), transport: clientConfiguration.transport ) @@ -74,10 +76,11 @@ enum NXRequestExecutor { private static func runtimeInterceptors( clientConfiguration: NXClientConfiguration, responseCacheStore: NXResponseCacheStore?, - requestSpec: RequestSpec + requestSpec: RequestSpec, + retryExecutionDependencies: NXRetryExecutionDependencies ) -> [any NXHTTPInterceptor] { var interceptors: [any NXHTTPInterceptor] = [ - NXRetryInterceptor(), + NXRetryInterceptor(dependencies: retryExecutionDependencies), NXAuthInterceptor() ] interceptors.append(contentsOf: clientConfiguration.interceptors) diff --git a/Tests/NexaTests/NXRetryExecutionTests.swift b/Tests/NexaTests/NXRetryExecutionTests.swift new file mode 100644 index 0000000..0f6c518 --- /dev/null +++ b/Tests/NexaTests/NXRetryExecutionTests.swift @@ -0,0 +1,214 @@ +// +// NXRetryExecutionTests.swift +// Nexa +// +// Created by opfic on 8/22/26. +// + +import Foundation +import Testing +@testable import Nexa + +@Suite("재시도 실행 순서 테스트") +struct NXRetryExecutionTests { + @Test("status 재시도는 requestEnd 뒤 retry log와 다음 requestStart를 남긴다") + func statusRetryPreservesLoggerOrder() async throws { + let logger = MemoryLogger() + let counter = AttemptCounter() + let configuration = makeConfiguration( + logger: logger, + transport: ClosureTransport { _ in + let attemptNumber = await counter.increment() + + if attemptNumber == 1 { + return makeRawResponse(statusCode: 503, body: "{}") + } + + return makeRawResponse(statusCode: 200, body: "{}") + } + ) + + _ = try await executeRaw( + configuration: configuration, + policy: NXRetryPolicy(maxAttempts: 2), + dependencies: noDelayDependencies() + ) + + #expect(await retryEvents(from: logger) == [ + .requestStart(1), + .requestEnd(1), + .retry(2), + .requestStart(2), + .requestEnd(2) + ]) + } + + @Test("전송 오류 재시도는 requestFailure 뒤 retry log를 남긴다") + func transportFailureRetryPreservesLoggerOrder() async throws { + let logger = MemoryLogger() + let counter = AttemptCounter() + let configuration = makeConfiguration( + logger: logger, + transport: ClosureTransport { _ in + let attemptNumber = await counter.increment() + + if attemptNumber == 1 { + throw URLError(.timedOut) + } + + return makeRawResponse(statusCode: 200, body: "{}") + } + ) + + _ = try await executeRaw( + configuration: configuration, + policy: NXRetryPolicy(maxAttempts: 2), + dependencies: noDelayDependencies() + ) + + #expect(await retryEvents(from: logger) == [ + .requestStart(1), + .requestFailure(1), + .retry(2), + .requestStart(2), + .requestEnd(2) + ]) + } + + @Test("대기 중 취소는 다음 request start와 transport attempt를 막는다") + func cancellationDuringRetryDelayPreventsNextAttempt() async { + let sleeper = RetrySleepGate() + let logger = MemoryLogger() + let counter = AttemptCounter() + let configuration = makeConfiguration( + logger: logger, + transport: ClosureTransport { _ in + _ = await counter.increment() + throw URLError(.timedOut) + } + ) + let policy = NXRetryPolicy(maxAttempts: 2, backoff: .fixed(1)) + let task = Task { + try await executeRaw( + configuration: configuration, + policy: policy, + dependencies: await sleeper.dependencies() + ) + } + + await sleeper.waitUntilStarted() + task.cancel() + + await #expect { + _ = try await task.value + } throws: { error in + guard case NXError.cancelled = error else { + return false + } + return true + } + + #expect(await counter.value() == 1) + #expect(await requestStartAttempts(from: logger) == [1]) + } + + private func makeConfiguration( + logger: any NXLogger, + transport: any NXHTTPTransport + ) -> NXClientConfiguration { + NXClientConfiguration( + baseURL: URL(string: "https://example.com")!, + transport: transport, + logger: logger + ) + } + + private func executeRaw( + configuration: NXClientConfiguration, + policy: NXRetryPolicy, + dependencies: NXRetryExecutionDependencies + ) async throws -> NXRawResponse { + var specification = RequestSpec(method: .get, path: "/users") + specification.retryPolicy = policy + + return try await NXRequestExecutor.executeRaw( + clientConfiguration: configuration, + responseCacheStore: nil, + requestSpec: specification, + retryExecutionDependencies: dependencies + ) + } + + private func noDelayDependencies() -> NXRetryExecutionDependencies { + NXRetryExecutionDependencies( + now: { Date() }, + sleep: { _ in }, + randomUnit: { 0 } + ) + } + + private func retryEvents(from logger: MemoryLogger) async -> [RetryEvent] { + await logger.allEvents().compactMap { event in + switch event { + case let .requestStart(log): + return .requestStart(log.attemptNumber) + case let .requestEnd(log): + return .requestEnd(log.attemptNumber) + case let .requestFailure(log): + return .requestFailure(log.attemptNumber) + case let .retry(log): + return .retry(log.nextAttemptNumber) + case .authRefresh: + return nil + } + } + } + + private func requestStartAttempts(from logger: MemoryLogger) async -> [Int] { + await logger.allEvents().compactMap { event in + guard case let .requestStart(log) = event else { + return nil + } + return log.attemptNumber + } + } +} + +private enum RetryEvent: Equatable { + case requestStart(Int) + case requestEnd(Int) + case requestFailure(Int) + case retry(Int) +} + +private actor RetrySleepGate { + private var hasStarted = false + private var startContinuation: CheckedContinuation? + + func dependencies() -> NXRetryExecutionDependencies { + NXRetryExecutionDependencies( + now: { Date() }, + sleep: { _ in + await self.markStarted() + try await Task.sleep(nanoseconds: .max) + }, + randomUnit: { 0 } + ) + } + + func waitUntilStarted() async { + if hasStarted { + return + } + + await withCheckedContinuation { continuation in + startContinuation = continuation + } + } + + private func markStarted() { + hasStarted = true + startContinuation?.resume() + startContinuation = nil + } +} From a6bf394bb4d91c7e411e8cedfe32d582b62e8ca5 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:34:08 +0900 Subject: [PATCH 6/7] =?UTF-8?q?docs:=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EC=95=88=EB=82=B4=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.ko.md | 21 +++++++++++++++++++++ README.md | 21 +++++++++++++++++++++ Sources/Nexa/Nexa.docc/Nexa.md | 14 ++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/README.ko.md b/README.ko.md index 1cc8e4b..ee34a80 100644 --- a/README.ko.md +++ b/README.ko.md @@ -17,6 +17,7 @@ Nexa는 `URLSession` 기반의 SwiftUI 스타일 선언형 네트워킹 라이 - [Endpoint API](#endpoint-api) - [요청 전환](#요청-전환) - [설정](#설정) +- [재시도 정책](#재시도-정책) - [개발](#개발) - [테스트](#테스트) @@ -343,6 +344,26 @@ let client = NXAPIClient(configuration: configuration) - 토큰 갱신 및 재시도 처리 - 스터빙 및 격리 테스트를 위한 커스텀 transport +## 재시도 정책 + +`NXRetryPolicy`는 설정한 재시도 상태 코드 또는 전송 오류가 발생하면 기본으로 `GET`, `HEAD`, `PUT`, `DELETE`, `OPTIONS`를 재시도합니다. 같은 요청을 반복해도 안전하게 처리하는 엔드포인트일 때만 `POST`, `PATCH`를 `retryableMethods`에 명시적으로 추가할 수 있습니다. + +재시도 가능한 `429`, `503` 응답에서는 `Retry-After`의 초 단위와 HTTP-date 값을 처리합니다. 유효한 서버 값은 local backoff를 대체하고 기본 60초인 `maximumServerDelay`로 제한되며 `NXRetryLog`에 기록됩니다. `Jitter.full`은 local backoff에만 적용되고 서버가 지정한 지연을 줄이지 않습니다. + +```swift +let retryPolicy = NXRetryPolicy( + maxAttempts: 3, + retryableMethods: [.get, .post], + maximumServerDelay: 30, + jitter: .none +) + +let user = try await client + .post("/users") + .retry(retryPolicy) + .send(as: User.self) +``` + ## 개발 Nexa는 배포되는 package graph에서 SwiftLint를 분리하여 패키지 소비자가 maintainer용 lint 규칙을 함께 받지 않도록 구성합니다. diff --git a/README.md b/README.md index ac998b8..aeb88dc 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Nexa is a SwiftUI-inspired declarative networking library built on `URLSession`. - [Endpoint API](#endpoint-api) - [Request Migration](#request-migration) - [Configuration](#configuration) +- [Retry Policy](#retry-policy) - [Development](#development) - [Testing](#testing) @@ -343,6 +344,26 @@ Nexa currently supports: - Token refresh and retry handling - Custom transports for stubbing and isolated tests +## Retry Policy + +`NXRetryPolicy` retries `GET`, `HEAD`, `PUT`, `DELETE`, and `OPTIONS` by default when a configured retryable status code or transport error occurs. `POST` and `PATCH` remain single-attempt requests unless you explicitly add them to `retryableMethods` for an endpoint that safely accepts repeated requests. + +For retryable `429` and `503` responses, Nexa accepts `Retry-After` delay seconds and HTTP-date values. A valid server value replaces local backoff, is capped by `maximumServerDelay` (60 seconds by default), and is recorded through `NXRetryLog`. `Jitter.full` changes only local backoff delays and never shortens a server-provided delay. + +```swift +let retryPolicy = NXRetryPolicy( + maxAttempts: 3, + retryableMethods: [.get, .post], + maximumServerDelay: 30, + jitter: .none +) + +let user = try await client + .post("/users") + .retry(retryPolicy) + .send(as: User.self) +``` + ## Development Nexa keeps SwiftLint out of the distributable package graph so package consumers do not inherit maintainer lint rules. diff --git a/Sources/Nexa/Nexa.docc/Nexa.md b/Sources/Nexa/Nexa.docc/Nexa.md index 5615329..11dde5d 100644 --- a/Sources/Nexa/Nexa.docc/Nexa.md +++ b/Sources/Nexa/Nexa.docc/Nexa.md @@ -48,6 +48,20 @@ let response = try await client .send() ``` +## Retry Policy + +``NXRetryPolicy`` retries `GET`, `HEAD`, `PUT`, `DELETE`, and `OPTIONS` by default when a configured status code or a retryable transport error occurs. Add `POST` or `PATCH` to `retryableMethods` only when the server can safely receive the same request more than once. + +For `429` and `503`, a valid `Retry-After` response header takes precedence over local backoff. Nexa accepts delay seconds and HTTP-date values, limits the result with `maximumServerDelay`, and records the selected delay through ``NXRetryLog``. Local ``NXRetryPolicy/Jitter`` does not change a server-provided delay. + +```swift +let policy = NXRetryPolicy( + maxAttempts: 3, + retryableMethods: [.get, .post], + maximumServerDelay: 30 +) +``` + ## Migration Nexa 1.3 removes `NXRequestBuilder.raw()` and `NXTypedRequestBuilder.raw()`. Use `NXRequestBuilder.send()` for a raw response. From cb761316dab63c6379488dac3ce6051eecc85fcc Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:29:09 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0=20=ED=98=95=EB=B3=80=ED=99=98=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EB=B0=A9=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NXRetryExecutionDependencies.swift | 24 ++++++++++++-- .../NXRetryExecutionDependenciesTests.swift | 31 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 Tests/NexaTests/NXRetryExecutionDependenciesTests.swift diff --git a/Sources/Nexa/Runtime/NXRetryExecutionDependencies.swift b/Sources/Nexa/Runtime/NXRetryExecutionDependencies.swift index bd3b7d3..f4878b5 100644 --- a/Sources/Nexa/Runtime/NXRetryExecutionDependencies.swift +++ b/Sources/Nexa/Runtime/NXRetryExecutionDependencies.swift @@ -15,12 +15,32 @@ struct NXRetryExecutionDependencies: Sendable { static let live = Self( now: { Date() }, sleep: { delay in - guard 0 < delay else { + let nanoseconds = Self.nanoseconds(for: delay) + + guard 0 < nanoseconds else { return } - try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + try await Task.sleep(nanoseconds: nanoseconds) }, randomUnit: { Double.random(in: 0...1) } ) + + static func nanoseconds(for delay: TimeInterval) -> UInt64 { + guard 0 < delay else { + return 0 + } + + guard delay.isFinite else { + return .max + } + + let maximumSeconds = TimeInterval(UInt64.max / 1_000_000_000) + + guard delay <= maximumSeconds else { + return .max + } + + return UInt64(delay * 1_000_000_000) + } } diff --git a/Tests/NexaTests/NXRetryExecutionDependenciesTests.swift b/Tests/NexaTests/NXRetryExecutionDependenciesTests.swift new file mode 100644 index 0000000..21054a1 --- /dev/null +++ b/Tests/NexaTests/NXRetryExecutionDependenciesTests.swift @@ -0,0 +1,31 @@ +// +// NXRetryExecutionDependenciesTests.swift +// Nexa +// +// Created by opfic on 8/23/26. +// + +import Foundation +import Testing +@testable import Nexa + +@Suite("재시도 대기 시간 변환 테스트") +struct NXRetryExecutionDependenciesTests { + @Test("일반 지연을 nanoseconds로 변환한다") + func convertsFiniteDelayToNanoseconds() { + #expect(NXRetryExecutionDependencies.nanoseconds(for: 1.5) == 1_500_000_000) + } + + @Test("매우 큰 지연과 infinity를 최대 nanoseconds로 제한한다", arguments: [ + TimeInterval.greatestFiniteMagnitude, + .infinity + ]) + func capsUnrepresentableDelayToMaximumNanoseconds(delay: TimeInterval) { + #expect(NXRetryExecutionDependencies.nanoseconds(for: delay) == .max) + } + + @Test("0 이하 지연은 대기하지 않는다", arguments: [0, -1]) + func convertsNonPositiveDelayToZeroNanoseconds(delay: TimeInterval) { + #expect(NXRetryExecutionDependencies.nanoseconds(for: delay) == 0) + } +}