diff --git a/.agents/rules/architecture.md b/.agents/rules/architecture.md index 53cdd44..3afd1d9 100644 --- a/.agents/rules/architecture.md +++ b/.agents/rules/architecture.md @@ -8,10 +8,10 @@ Nexa as Swift Package networking library. Public API stability, request behavior | Area | Owner | Responsibility | | --- | --- | --- | -| `Sources/Nexa/Public` | Public API | Consumer-facing request builders, client, endpoint, and extension protocols | -| `Sources/Nexa/Core` | Core model | Request configuration, request model, policy, error, logging, and protocol contracts | -| `Sources/Nexa/Runtime` | Runtime | Request assembly, execution, transport, interceptor chain, retry, authentication, cache, and response pipeline | -| `Tests/NexaTests` | Test suite | Observable public behavior and runtime boundary verification | +| `Sources/Public` | Public API | Consumer-facing request builders, client, endpoint, and extension protocols | +| `Sources/Core` | Core model | Request configuration, request model, policy, error, logging, and protocol contracts | +| `Sources/Runtime` | Runtime | Request assembly, execution, transport, interceptor chain, retry, authentication, cache, and response pipeline | +| `Tests` | Test suite | Observable public behavior and runtime boundary verification | | `Package.swift` | Package manifest | Platform floor, product, target, test target, and package dependency declarations | ## Public API rules diff --git a/Examples/NexaClient/NexaClient/ContentView.swift b/Examples/NexaClient/ContentView.swift similarity index 100% rename from Examples/NexaClient/NexaClient/ContentView.swift rename to Examples/NexaClient/ContentView.swift diff --git a/Examples/NexaClient/NexaClient.xcodeproj/project.pbxproj b/Examples/NexaClient/NexaClient.xcodeproj/project.pbxproj index 141128b..cda1131 100644 --- a/Examples/NexaClient/NexaClient.xcodeproj/project.pbxproj +++ b/Examples/NexaClient/NexaClient.xcodeproj/project.pbxproj @@ -16,9 +16,9 @@ /* Begin PBXFileReference section */ 1E4507C23C16857EDCCB7C77 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; - 602E17E5451FC46F0B078998 /* NexaClientApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NexaClientApp.swift; path = NexaClient/NexaClientApp.swift; sourceTree = ""; }; - 9398B6CF8522A7EC915B1B82 /* NexaIntegrationPreview.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NexaIntegrationPreview.swift; path = NexaClient/NexaIntegrationPreview.swift; sourceTree = ""; }; - C3CC707D1B1D4215893D0C24 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ContentView.swift; path = NexaClient/ContentView.swift; sourceTree = ""; }; + 602E17E5451FC46F0B078998 /* NexaClientApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NexaClientApp.swift; path = NexaClientApp.swift; sourceTree = ""; }; + 9398B6CF8522A7EC915B1B82 /* NexaIntegrationPreview.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NexaIntegrationPreview.swift; path = NexaIntegrationPreview.swift; sourceTree = ""; }; + C3CC707D1B1D4215893D0C24 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ContentView.swift; path = ContentView.swift; sourceTree = ""; }; EBFB666B6AFCF02FCBA1B5A2 /* NexaClient.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; name = NexaClient.app; path = NexaClient.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ diff --git a/Examples/NexaClient/NexaClient/NexaClientApp.swift b/Examples/NexaClient/NexaClientApp.swift similarity index 100% rename from Examples/NexaClient/NexaClient/NexaClientApp.swift rename to Examples/NexaClient/NexaClientApp.swift diff --git a/Examples/NexaClient/NexaClient/NexaIntegrationPreview.swift b/Examples/NexaClient/NexaIntegrationPreview.swift similarity index 100% rename from Examples/NexaClient/NexaClient/NexaIntegrationPreview.swift rename to Examples/NexaClient/NexaIntegrationPreview.swift diff --git a/Package.swift b/Package.swift index 11ff48a..bf5b97e 100644 --- a/Package.swift +++ b/Package.swift @@ -17,10 +17,14 @@ let package = Package( ], dependencies: [], targets: [ - .target(name: "Nexa"), + .target( + name: "Nexa", + path: "Sources" + ), .testTarget( name: "NexaTests", - dependencies: ["Nexa"] + dependencies: ["Nexa"], + path: "Tests" ), ], swiftLanguageModes: [.v6] diff --git a/README.ko.md b/README.ko.md index ee34a80..75af9ff 100644 --- a/README.ko.md +++ b/README.ko.md @@ -78,7 +78,8 @@ dependencies: [ | `NXEndpoint` | 엔드포인트 정의를 재사용하고 응답 타입을 함께 관리할 때 | `try await client.send(UserEndpoint(identifier: 1))` | | `NXClientConfiguration` | 공통 헤더, transport, 로거, 인증, 인코더, 디코더, 인터셉터를 한 번에 설정할 때 | `NXClientConfiguration(baseURL: url, authTokenProvider: yourAuthTokenProvider)` | | `NXCache` | 인증이 필요 없는 성공한 `GET` 응답을 짧은 TTL 동안 재사용할 때 | `NXClientConfiguration(baseURL: url, cache: .memory(ttl: 0.3))` | -| `NXRetryPolicy` | 재시도 가능한 상태 코드나 전송 오류 시 재시도할 때 | `.retry(.init(maxAttempts: 3))` | +| `NXRetryBackoff` | 고정 또는 지수 재시도 지연이 필요할 때 | `.retry(maxAttempts: 3, backoff: .fixed(0))` | +| `NXRetryJitter` | local 재시도 지연의 무작위 처리가 필요할 때 | `.retry(maxAttempts: 3, jitter: .full)` | | `NXValidationPolicy` | 허용할 상태 코드가 기본값(`200..<300`)과 다를 때 | `.validate(.statusCodes([200, 201, 204]))` | | `NXHTTPTransport` | 테스트용 스텁이 필요하거나 transport 구현을 교체할 때 | `NXClientConfiguration(baseURL: url, transport: yourStubTransport)` | | `NXHTTPInterceptor` | 트레이싱이나 헤더 주입처럼 요청 전반에 적용되는 처리가 필요할 때 | `.intercept(yourInterceptor)` | @@ -346,24 +347,31 @@ let client = NXAPIClient(configuration: configuration) ## 재시도 정책 -`NXRetryPolicy`는 설정한 재시도 상태 코드 또는 전송 오류가 발생하면 기본으로 `GET`, `HEAD`, `PUT`, `DELETE`, `OPTIONS`를 재시도합니다. 같은 요청을 반복해도 안전하게 처리하는 엔드포인트일 때만 `POST`, `PATCH`를 `retryableMethods`에 명시적으로 추가할 수 있습니다. +`.retry(...)`는 설정한 재시도 상태 코드 또는 전송 오류가 발생하면 기본으로 `GET`, `HEAD`, `PUT`, `DELETE`, `OPTIONS`를 재시도합니다. `maxAttempts`를 생략하면 세 번 시도합니다. 같은 요청을 반복해도 안전하게 처리하는 엔드포인트일 때만 `POST`, `PATCH`를 `allowing`에 명시적으로 추가할 수 있습니다. -재시도 가능한 `429`, `503` 응답에서는 `Retry-After`의 초 단위와 HTTP-date 값을 처리합니다. 유효한 서버 값은 local backoff를 대체하고 기본 60초인 `maximumServerDelay`로 제한되며 `NXRetryLog`에 기록됩니다. `Jitter.full`은 local backoff에만 적용되고 서버가 지정한 지연을 줄이지 않습니다. +재시도 가능한 `429`, `503` 응답에서는 `Retry-After`의 초 단위와 HTTP-date 값을 처리합니다. 유효한 서버 값은 local backoff를 대체하고 기본 60초인 `maximumServerDelay`로 제한되며 `NXRetryLog`에 기록됩니다. `NXRetryJitter.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) + .retry( + maxAttempts: 3, + backoff: .fixed(0), + allowing: [.post], + maximumServerDelay: 30, + jitter: .none + ) .send(as: User.self) ``` +## Nexa 1.3 전환 + +Nexa 1.3에서는 공개 `NXRetryPolicy` 생성자, `NXRetryPolicy.Backoff`, `NXRetryPolicy.Jitter`, `.retry(_:)`를 제거합니다. `NXRetryPolicy`는 internal 구현 타입으로 유지합니다. `NXRetryBackoff`, `NXRetryJitter`와 `.retry(maxAttempts:backoff:retryableStatusCodes:allowing:maximumServerDelay:jitter:)`를 사용하며 `maxAttempts`의 기본값은 `3`입니다. + +## Interceptor method 계약 + +`NXHTTPInterceptor.replacingRequest(_:)`는 request URL, header, body를 바꿀 수 있지만 method는 설정한 method와 같아야 합니다. 다른 method는 이후 interceptor, logger, cache, transport 실행 전에 `NXError.invalidRequest`로 종료됩니다. + ## 개발 Nexa는 배포되는 package graph에서 SwiftLint를 분리하여 패키지 소비자가 maintainer용 lint 규칙을 함께 받지 않도록 구성합니다. diff --git a/README.md b/README.md index aeb88dc..d0f2264 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,8 @@ The rest of the public surface is made of extension points for auth, logging, te | `NXEndpoint` | When an endpoint definition should be reusable and carry its response type with it | `try await client.send(UserEndpoint(identifier: 1))` | | `NXClientConfiguration` | When shared headers, transport, logger, auth, encoder, decoder, or interceptors should be configured once | `NXClientConfiguration(baseURL: url, authTokenProvider: yourAuthTokenProvider)` | | `NXCache` | When successful unauthenticated `GET` responses should be reused for a short TTL | `NXClientConfiguration(baseURL: url, cache: .memory(ttl: 0.3))` | -| `NXRetryPolicy` | When a request should retry on retryable status codes or transport failures | `.retry(.init(maxAttempts: 3))` | +| `NXRetryBackoff` | When retry delays need fixed or exponential behavior | `.retry(maxAttempts: 3, backoff: .fixed(0))` | +| `NXRetryJitter` | When local retry delay randomization is needed | `.retry(maxAttempts: 3, jitter: .full)` | | `NXValidationPolicy` | When the accepted status codes differ from the default `200..<300` | `.validate(.statusCodes([200, 201, 204]))` | | `NXHTTPTransport` | When you need stubs in tests or want to replace the transport implementation | `NXClientConfiguration(baseURL: url, transport: yourStubTransport)` | | `NXHTTPInterceptor` | When you need cross-cutting request behavior such as tracing or header injection | `.intercept(yourInterceptor)` | @@ -346,24 +347,31 @@ Nexa currently supports: ## 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. +`.retry(...)` retries `GET`, `HEAD`, `PUT`, `DELETE`, and `OPTIONS` by default when a configured retryable status code or transport error occurs. It uses three attempts when `maxAttempts` is omitted. `POST` and `PATCH` remain single-attempt requests unless you explicitly add them through `allowing` 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. +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`. `NXRetryJitter.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) + .retry( + maxAttempts: 3, + backoff: .fixed(0), + allowing: [.post], + maximumServerDelay: 30, + jitter: .none + ) .send(as: User.self) ``` +## Nexa 1.3 Migration + +The public `NXRetryPolicy` constructor, `NXRetryPolicy.Backoff`, `NXRetryPolicy.Jitter`, and `.retry(_:)` are removed in Nexa 1.3. Nexa keeps `NXRetryPolicy` as an internal implementation detail. Use `.retry(maxAttempts:backoff:retryableStatusCodes:allowing:maximumServerDelay:jitter:)` with `NXRetryBackoff` and `NXRetryJitter` instead; `maxAttempts` defaults to `3`. + +## Interceptor Method Contract + +`NXHTTPInterceptor.replacingRequest(_:)` can change a request URL, headers, and body, but the request method must remain equal to the configured method. A different method ends the chain with `NXError.invalidRequest` before later interceptors, logging, caching, or transport. + ## Development Nexa keeps SwiftLint out of the distributable package graph so package consumers do not inherit maintainer lint rules. diff --git a/Sources/Nexa/Core/NXCache.swift b/Sources/Core/NXCache.swift similarity index 100% rename from Sources/Nexa/Core/NXCache.swift rename to Sources/Core/NXCache.swift diff --git a/Sources/Nexa/Core/NXClientConfiguration.swift b/Sources/Core/NXClientConfiguration.swift similarity index 100% rename from Sources/Nexa/Core/NXClientConfiguration.swift rename to Sources/Core/NXClientConfiguration.swift diff --git a/Sources/Nexa/Core/NXError.swift b/Sources/Core/NXError.swift similarity index 100% rename from Sources/Nexa/Core/NXError.swift rename to Sources/Core/NXError.swift diff --git a/Sources/Nexa/Core/NXHTTPMethod.swift b/Sources/Core/NXHTTPMethod.swift similarity index 100% rename from Sources/Nexa/Core/NXHTTPMethod.swift rename to Sources/Core/NXHTTPMethod.swift diff --git a/Sources/Nexa/Core/NXLogging.swift b/Sources/Core/NXLogging.swift similarity index 100% rename from Sources/Nexa/Core/NXLogging.swift rename to Sources/Core/NXLogging.swift diff --git a/Sources/Nexa/Core/NXProtocols.swift b/Sources/Core/NXProtocols.swift similarity index 100% rename from Sources/Nexa/Core/NXProtocols.swift rename to Sources/Core/NXProtocols.swift diff --git a/Sources/Nexa/Core/NXRawResponse.swift b/Sources/Core/NXRawResponse.swift similarity index 100% rename from Sources/Nexa/Core/NXRawResponse.swift rename to Sources/Core/NXRawResponse.swift diff --git a/Sources/Nexa/Core/NXRequestBody.swift b/Sources/Core/NXRequestBody.swift similarity index 100% rename from Sources/Nexa/Core/NXRequestBody.swift rename to Sources/Core/NXRequestBody.swift diff --git a/Sources/Nexa/Core/NXRequestSpec.swift b/Sources/Core/NXRequestSpec.swift similarity index 100% rename from Sources/Nexa/Core/NXRequestSpec.swift rename to Sources/Core/NXRequestSpec.swift diff --git a/Sources/Core/NXRetryBackoff.swift b/Sources/Core/NXRetryBackoff.swift new file mode 100644 index 0000000..df9bc24 --- /dev/null +++ b/Sources/Core/NXRetryBackoff.swift @@ -0,0 +1,27 @@ +// +// NXRetryBackoff.swift +// Nexa +// +// Created by opfic on 8/23/26. +// + +import Foundation + +/// Delay strategy used between retry attempts. +public enum NXRetryBackoff: Sendable { + /// Uses a fixed delay for every retry attempt. + case fixed(TimeInterval) + /// Doubles the delay every attempt until the maximum delay is reached. + case exponential(base: TimeInterval, maxDelay: TimeInterval) + + func delay(forAttempt attemptNumber: Int) -> TimeInterval { + switch self { + case let .fixed(seconds): + return max(0, seconds) + case let .exponential(base, maxDelay): + let exponent = max(0, attemptNumber - 1) + let computedDelay = base * pow(2, Double(exponent)) + return min(maxDelay, max(0, computedDelay)) + } + } +} diff --git a/Sources/Core/NXRetryJitter.swift b/Sources/Core/NXRetryJitter.swift new file mode 100644 index 0000000..445f13e --- /dev/null +++ b/Sources/Core/NXRetryJitter.swift @@ -0,0 +1,14 @@ +// +// NXRetryJitter.swift +// Nexa +// +// Created by opfic on 8/23/26. +// + +/// Randomization applied to local retry backoff delays. +public enum NXRetryJitter: Sendable, Equatable { + /// Keeps the local backoff delay unchanged. + case none + /// Uses a random value within the local backoff delay range. + case full +} diff --git a/Sources/Core/NXRetryPolicy.swift b/Sources/Core/NXRetryPolicy.swift new file mode 100644 index 0000000..cc2e8c2 --- /dev/null +++ b/Sources/Core/NXRetryPolicy.swift @@ -0,0 +1,35 @@ +// +// NXRetryPolicy.swift +// Nexa +// +// Created by opfic on 8/23/26. +// + +import Foundation + +struct NXRetryPolicy: Sendable { + let maxAttempts: Int + let backoff: NXRetryBackoff + let retryableStatusCodes: Set + let allowedMethods: Set + let maximumServerDelay: TimeInterval + let jitter: NXRetryJitter + + init( + maxAttempts: Int, + backoff: NXRetryBackoff = .fixed(0), + retryableStatusCodes: Set = [408, 429, 500, 502, 503, 504], + allowing: Set = [], + maximumServerDelay: TimeInterval = 60, + jitter: NXRetryJitter = .none + ) { + self.maxAttempts = max(1, maxAttempts) + self.backoff = backoff + self.retryableStatusCodes = retryableStatusCodes + var allowedMethods: Set = [.get, .head, .put, .delete, .options] + allowedMethods.formUnion(allowing) + self.allowedMethods = allowedMethods + self.maximumServerDelay = max(0, maximumServerDelay) + self.jitter = jitter + } +} diff --git a/Sources/Nexa/Core/NXValidationPolicy.swift b/Sources/Core/NXValidationPolicy.swift similarity index 100% rename from Sources/Nexa/Core/NXValidationPolicy.swift rename to Sources/Core/NXValidationPolicy.swift diff --git a/Sources/Nexa/Nexa.docc/Nexa.md b/Sources/Nexa.docc/Nexa.md similarity index 69% rename from Sources/Nexa/Nexa.docc/Nexa.md rename to Sources/Nexa.docc/Nexa.md index 11dde5d..bffb7ce 100644 --- a/Sources/Nexa/Nexa.docc/Nexa.md +++ b/Sources/Nexa.docc/Nexa.md @@ -50,21 +50,26 @@ let response = try await client ## 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. +``NXRequestBuilder/retry(maxAttempts:backoff:retryableStatusCodes:allowing:maximumServerDelay:jitter:)`` retries `GET`, `HEAD`, `PUT`, `DELETE`, and `OPTIONS` by default when a configured status code or a retryable transport error occurs. It uses three attempts when `maxAttempts` is omitted. Add `POST` or `PATCH` through `allowing` 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. +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 ``NXRetryJitter`` does not change a server-provided delay. ```swift -let policy = NXRetryPolicy( - maxAttempts: 3, - retryableMethods: [.get, .post], - maximumServerDelay: 30 -) +let user = try await client + .post("/users") + .retry( + maxAttempts: 3, + allowing: [.post], + maximumServerDelay: 30 + ) + .send(as: User.self) ``` ## Migration -Nexa 1.3 removes `NXRequestBuilder.raw()` and `NXTypedRequestBuilder.raw()`. Use `NXRequestBuilder.send()` for a raw response. +Nexa 1.3 removes `NXRequestBuilder.raw()`, `NXTypedRequestBuilder.raw()`, the public `NXRetryPolicy` constructor, `NXRetryPolicy.Backoff`, `NXRetryPolicy.Jitter`, and `.retry(_:)`. `NXRetryPolicy` remains an internal implementation detail. Use `NXRequestBuilder.send()` for a raw response and `.retry(maxAttempts:backoff:retryableStatusCodes:allowing:maximumServerDelay:jitter:)` for retry behavior; `maxAttempts` defaults to `3`. + +An ``NXHTTPInterceptor`` can change a request URL, headers, and body through ``NXRequestExecutionContext/replacingRequest(_:)``, but must preserve the configured HTTP method. A different method ends with ``NXError/invalidRequest(_:)`` before later interceptors, logging, caching, or transport. `NXEndpoint` retains its typed configuration and decoded `client.send(_:)` path. It does not provide a raw-response execution API; construct the required request directly with `NXRequestBuilder` when raw response handling is required. @@ -108,7 +113,8 @@ let user = try await client.send(UserEndpoint(identifier: 42)) - ``NXRawResponse`` - ``NXError`` - ``NXValidationPolicy`` -- ``NXRetryPolicy`` +- ``NXRetryBackoff`` +- ``NXRetryJitter`` - ``NXURLSessionTransport`` ### Extension Points diff --git a/Sources/Nexa/Core/NXRetryPolicy.swift b/Sources/Nexa/Core/NXRetryPolicy.swift deleted file mode 100644 index 7a023e9..0000000 --- a/Sources/Nexa/Core/NXRetryPolicy.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// NXRetryPolicy.swift -// Nexa -// -// Created by 최윤진 on 4/12/26. -// - -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 { - /// Uses a fixed delay for every retry attempt. - case fixed(TimeInterval) - /// Doubles the delay every attempt until the maximum delay is reached. - case exponential(base: TimeInterval, maxDelay: TimeInterval) - - func delay(forAttempt attemptNumber: Int) -> TimeInterval { - switch self { - case let .fixed(seconds): - return max(0, seconds) - case let .exponential(base, maxDelay): - let exponent = max(0, attemptNumber - 1) - let computedDelay = base * pow(2, Double(exponent)) - return min(maxDelay, max(0, computedDelay)) - } - } - } - - /// 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. - /// - /// - Parameters: - /// - 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], - 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/Sources/Nexa/Public/NXAPIClient.swift b/Sources/Public/NXAPIClient.swift similarity index 100% rename from Sources/Nexa/Public/NXAPIClient.swift rename to Sources/Public/NXAPIClient.swift diff --git a/Sources/Nexa/Public/NXEndpoint.swift b/Sources/Public/NXEndpoint.swift similarity index 100% rename from Sources/Nexa/Public/NXEndpoint.swift rename to Sources/Public/NXEndpoint.swift diff --git a/Sources/Nexa/Public/NXHTTPInterceptor.swift b/Sources/Public/NXHTTPInterceptor.swift similarity index 89% rename from Sources/Nexa/Public/NXHTTPInterceptor.swift rename to Sources/Public/NXHTTPInterceptor.swift index da79a72..0fcfc74 100644 --- a/Sources/Nexa/Public/NXHTTPInterceptor.swift +++ b/Sources/Public/NXHTTPInterceptor.swift @@ -11,7 +11,7 @@ import Foundation /// /// ## Overview /// -/// Implement an interceptor when you need cross-cutting request behavior such as tracing, custom headers, or response observation. +/// Implement an interceptor when you need cross-cutting request behavior such as tracing, custom headers, or response observation. An interceptor can replace request URL, headers, and body, but must preserve the configured HTTP method. /// /// ```swift /// import Foundation @@ -59,8 +59,10 @@ public struct NXRequestExecutionContext: Sendable { /// Returns a copy of the context with a different request value. /// - /// - Parameter request: Replacement request to use for the remaining chain. + /// - Parameter request: Replacement request to use for the remaining chain. Its `httpMethod` must equal the configured request method. /// - Returns: A new execution context with the updated request. + /// + /// Passing a request with a different or missing `httpMethod` ends execution with ``NXError/invalidRequest(_:)`` before later interceptors, logging, caching, or transport. public func replacingRequest(_ request: URLRequest) -> Self { Self( request: request, diff --git a/Sources/Nexa/Public/NXRequestBuilder.swift b/Sources/Public/NXRequestBuilder.swift similarity index 86% rename from Sources/Nexa/Public/NXRequestBuilder.swift rename to Sources/Public/NXRequestBuilder.swift index 505e9cc..7296837 100644 --- a/Sources/Nexa/Public/NXRequestBuilder.swift +++ b/Sources/Public/NXRequestBuilder.swift @@ -144,13 +144,33 @@ public struct NXRequestBuilder: Sendable { } } - /// Applies a retry policy to the request. + /// Applies retry behavior to the request. /// - /// - Parameter policy: Retry behavior to use during execution. + /// - Parameters: + /// - maxAttempts: Maximum number of attempts including the initial request. Defaults to `3`. + /// - backoff: Delay strategy used between retry attempts. + /// - retryableStatusCodes: Status codes that should trigger a retry. + /// - allowing: Additional HTTP methods that can retry alongside the default idempotent methods. + /// - maximumServerDelay: Upper limit applied to a server-provided retry delay. + /// - jitter: Randomization applied to local backoff delays. /// - Returns: Updated request builder. - public func retry(_ policy: NXRetryPolicy) -> Self { + public func retry( + maxAttempts: Int = 3, + backoff: NXRetryBackoff = .fixed(0), + retryableStatusCodes: Set = [408, 429, 500, 502, 503, 504], + allowing: Set = [], + maximumServerDelay: TimeInterval = 60, + jitter: NXRetryJitter = .none + ) -> Self { modifying { requestSpec in - requestSpec.retryPolicy = policy + requestSpec.retryPolicy = NXRetryPolicy( + maxAttempts: maxAttempts, + backoff: backoff, + retryableStatusCodes: retryableStatusCodes, + allowing: allowing, + maximumServerDelay: maximumServerDelay, + jitter: jitter + ) } } @@ -225,6 +245,10 @@ public struct NXRequestBuilder: Sendable { ) } + var retryPolicy: NXRetryPolicy? { + requestSpec.retryPolicy + } + func modifying(_ update: (inout RequestSpec) throws -> Void) rethrows -> Self { var copiedRequestSpec = requestSpec try update(&copiedRequestSpec) diff --git a/Sources/Nexa/Public/NXTypedRequestBuilder.swift b/Sources/Public/NXTypedRequestBuilder.swift similarity index 80% rename from Sources/Nexa/Public/NXTypedRequestBuilder.swift rename to Sources/Public/NXTypedRequestBuilder.swift index 1c5d11f..861bf87 100644 --- a/Sources/Nexa/Public/NXTypedRequestBuilder.swift +++ b/Sources/Public/NXTypedRequestBuilder.swift @@ -108,12 +108,32 @@ public struct NXTypedRequestBuilder: Sendable where Response: Decodabl Self(requestBuilder: requestBuilder.contentType(value)) } - /// Applies a retry policy to the request. + /// Applies retry behavior to the request. /// - /// - Parameter policy: Retry behavior to use during execution. + /// - Parameters: + /// - maxAttempts: Maximum number of attempts including the initial request. Defaults to `3`. + /// - backoff: Delay strategy used between retry attempts. + /// - retryableStatusCodes: Status codes that should trigger a retry. + /// - allowing: Additional HTTP methods that can retry alongside the default idempotent methods. + /// - maximumServerDelay: Upper limit applied to a server-provided retry delay. + /// - jitter: Randomization applied to local backoff delays. /// - Returns: Updated typed request builder. - public func retry(_ policy: NXRetryPolicy) -> Self { - Self(requestBuilder: requestBuilder.retry(policy)) + public func retry( + maxAttempts: Int = 3, + backoff: NXRetryBackoff = .fixed(0), + retryableStatusCodes: Set = [408, 429, 500, 502, 503, 504], + allowing: Set = [], + maximumServerDelay: TimeInterval = 60, + jitter: NXRetryJitter = .none + ) -> Self { + Self(requestBuilder: requestBuilder.retry( + maxAttempts: maxAttempts, + backoff: backoff, + retryableStatusCodes: retryableStatusCodes, + allowing: allowing, + maximumServerDelay: maximumServerDelay, + jitter: jitter + )) } /// Applies a response validation policy to the request. @@ -146,4 +166,8 @@ public struct NXTypedRequestBuilder: Sendable where Response: Decodabl public func send() async throws -> Response { try await requestBuilder.send(as: Response.self) } + + var retryPolicy: NXRetryPolicy? { + requestBuilder.retryPolicy + } } diff --git a/Sources/Nexa/Runtime/NXAuthInterceptor.swift b/Sources/Runtime/NXAuthInterceptor.swift similarity index 100% rename from Sources/Nexa/Runtime/NXAuthInterceptor.swift rename to Sources/Runtime/NXAuthInterceptor.swift diff --git a/Sources/Nexa/Runtime/NXInterceptorChain.swift b/Sources/Runtime/NXInterceptorChain.swift similarity index 78% rename from Sources/Nexa/Runtime/NXInterceptorChain.swift rename to Sources/Runtime/NXInterceptorChain.swift index 1020843..41799de 100644 --- a/Sources/Nexa/Runtime/NXInterceptorChain.swift +++ b/Sources/Runtime/NXInterceptorChain.swift @@ -14,6 +14,10 @@ enum NXInterceptorChain { transport: any NXHTTPTransport ) async throws -> NXRawResponse { @Sendable func proceed(index: Int, context: NXRequestExecutionContext) async throws -> NXRawResponse { + guard context.request.httpMethod == context.specification.method.rawValue else { + throw NXError.invalidRequest("Interceptor request method must match the request specification method.") + } + if index < interceptors.count { return try await interceptors[index].intercept(context: context) { nextContext in try await proceed(index: index + 1, context: nextContext) diff --git a/Sources/Nexa/Runtime/NXLoggerInterceptor.swift b/Sources/Runtime/NXLoggerInterceptor.swift similarity index 100% rename from Sources/Nexa/Runtime/NXLoggerInterceptor.swift rename to Sources/Runtime/NXLoggerInterceptor.swift diff --git a/Sources/Nexa/Runtime/NXRequestAssembler.swift b/Sources/Runtime/NXRequestAssembler.swift similarity index 100% rename from Sources/Nexa/Runtime/NXRequestAssembler.swift rename to Sources/Runtime/NXRequestAssembler.swift diff --git a/Sources/Nexa/Runtime/NXRequestCacheKey.swift b/Sources/Runtime/NXRequestCacheKey.swift similarity index 100% rename from Sources/Nexa/Runtime/NXRequestCacheKey.swift rename to Sources/Runtime/NXRequestCacheKey.swift diff --git a/Sources/Nexa/Runtime/NXRequestExecutor.swift b/Sources/Runtime/NXRequestExecutor.swift similarity index 100% rename from Sources/Nexa/Runtime/NXRequestExecutor.swift rename to Sources/Runtime/NXRequestExecutor.swift diff --git a/Sources/Nexa/Runtime/NXResponseCacheInterceptor.swift b/Sources/Runtime/NXResponseCacheInterceptor.swift similarity index 100% rename from Sources/Nexa/Runtime/NXResponseCacheInterceptor.swift rename to Sources/Runtime/NXResponseCacheInterceptor.swift diff --git a/Sources/Nexa/Runtime/NXResponseCacheStore.swift b/Sources/Runtime/NXResponseCacheStore.swift similarity index 100% rename from Sources/Nexa/Runtime/NXResponseCacheStore.swift rename to Sources/Runtime/NXResponseCacheStore.swift diff --git a/Sources/Nexa/Runtime/NXResponsePipeline.swift b/Sources/Runtime/NXResponsePipeline.swift similarity index 100% rename from Sources/Nexa/Runtime/NXResponsePipeline.swift rename to Sources/Runtime/NXResponsePipeline.swift diff --git a/Sources/Nexa/Runtime/NXRetryExecutionDependencies.swift b/Sources/Runtime/NXRetryExecutionDependencies.swift similarity index 100% rename from Sources/Nexa/Runtime/NXRetryExecutionDependencies.swift rename to Sources/Runtime/NXRetryExecutionDependencies.swift diff --git a/Sources/Nexa/Runtime/NXRetryInterceptor.swift b/Sources/Runtime/NXRetryInterceptor.swift similarity index 98% rename from Sources/Nexa/Runtime/NXRetryInterceptor.swift rename to Sources/Runtime/NXRetryInterceptor.swift index 9b44e9a..8a7de88 100644 --- a/Sources/Nexa/Runtime/NXRetryInterceptor.swift +++ b/Sources/Runtime/NXRetryInterceptor.swift @@ -22,7 +22,7 @@ struct NXRetryInterceptor: NXHTTPInterceptor { return try await next(context) } - guard retryPolicy.retryableMethods.contains(context.specification.method) else { + guard retryPolicy.allowedMethods.contains(context.specification.method) else { return try await next(context) } diff --git a/Sources/Nexa/Runtime/NXURLSessionTransport.swift b/Sources/Runtime/NXURLSessionTransport.swift similarity index 100% rename from Sources/Nexa/Runtime/NXURLSessionTransport.swift rename to Sources/Runtime/NXURLSessionTransport.swift diff --git a/Tests/NexaTests/Fixtures/NXNetworkTestSupport.swift b/Tests/Fixtures/NXNetworkTestSupport.swift similarity index 100% rename from Tests/NexaTests/Fixtures/NXNetworkTestSupport.swift rename to Tests/Fixtures/NXNetworkTestSupport.swift diff --git a/Tests/NexaTests/NXClientConfigurationProtocolTests.swift b/Tests/NXClientConfigurationProtocolTests.swift similarity index 100% rename from Tests/NexaTests/NXClientConfigurationProtocolTests.swift rename to Tests/NXClientConfigurationProtocolTests.swift diff --git a/Tests/NexaTests/NXCoreModelPolicyTests.swift b/Tests/NXCoreModelPolicyTests.swift similarity index 94% rename from Tests/NexaTests/NXCoreModelPolicyTests.swift rename to Tests/NXCoreModelPolicyTests.swift index a793bf6..e664a16 100644 --- a/Tests/NexaTests/NXCoreModelPolicyTests.swift +++ b/Tests/NXCoreModelPolicyTests.swift @@ -50,10 +50,10 @@ struct NXCoreModelPolicyTests { #expect(retryPolicy.maxAttempts == 1) - let fixedDelay = NXRetryPolicy.Backoff.fixed(-1) + let fixedDelay = NXRetryBackoff.fixed(-1) #expect(fixedDelay.delay(forAttempt: 1) == 0) - let exponentialDelay = NXRetryPolicy.Backoff.exponential(base: 0.5, maxDelay: 2) + let exponentialDelay = NXRetryBackoff.exponential(base: 0.5, maxDelay: 2) #expect(exponentialDelay.delay(forAttempt: 1) == 0.5) #expect(exponentialDelay.delay(forAttempt: 2) == 1.0) #expect(exponentialDelay.delay(forAttempt: 3) == 2.0) @@ -65,7 +65,7 @@ struct NXCoreModelPolicyTests { let defaultRetryPolicy = NXRetryPolicy(maxAttempts: 2) let retryPolicy = NXRetryPolicy(maxAttempts: 2, maximumServerDelay: -1) - #expect(defaultRetryPolicy.retryableMethods == [.get, .head, .put, .delete, .options]) + #expect(defaultRetryPolicy.allowedMethods == [.get, .head, .put, .delete, .options]) #expect(defaultRetryPolicy.maximumServerDelay == 60) #expect(defaultRetryPolicy.jitter == .none) #expect(retryPolicy.maximumServerDelay == 0) diff --git a/Tests/NexaTests/NXDeclarativeRequestDSLTests.swift b/Tests/NXDeclarativeRequestDSLTests.swift similarity index 100% rename from Tests/NexaTests/NXDeclarativeRequestDSLTests.swift rename to Tests/NXDeclarativeRequestDSLTests.swift diff --git a/Tests/NexaTests/NXInterceptorChainTests.swift b/Tests/NXInterceptorChainTests.swift similarity index 99% rename from Tests/NexaTests/NXInterceptorChainTests.swift rename to Tests/NXInterceptorChainTests.swift index 92d3845..cfe595e 100644 --- a/Tests/NexaTests/NXInterceptorChainTests.swift +++ b/Tests/NXInterceptorChainTests.swift @@ -84,7 +84,7 @@ struct NXInterceptorChainTests { let user = try await client .get("/users") - .retry(NXRetryPolicy(maxAttempts: 3)) + .retry(maxAttempts: 3) .send(as: UserDTO.self) #expect(user == UserDTO(id: 3, name: "retry")) diff --git a/Tests/NXInterceptorMethodInvariantTests.swift b/Tests/NXInterceptorMethodInvariantTests.swift new file mode 100644 index 0000000..e6c27b7 --- /dev/null +++ b/Tests/NXInterceptorMethodInvariantTests.swift @@ -0,0 +1,110 @@ +// +// NXInterceptorMethodInvariantTests.swift +// Nexa +// +// Created by opfic on 8/23/26. +// + +import Foundation +import Testing +@testable import Nexa + +@Suite("인터셉터 method 불변 계약 테스트") +struct NXInterceptorMethodInvariantTests { + @Test("global interceptor의 GET에서 POST 변경은 전송 전에 차단한다") + func globalInterceptorChangingGetToPostIsRejected() async { + let counter = AttemptCounter() + let logger = MemoryLogger() + let client = makeClient( + counter: counter, + logger: logger, + interceptors: [MethodReplacingInterceptor(method: "POST")] + ) + + await expectInvalidRequest( + client.get("/users"), + counter: counter, + logger: logger + ) + } + + @Test("request interceptor의 GET에서 PATCH 변경은 전송 전에 차단한다") + func requestInterceptorChangingGetToPatchIsRejected() async { + let counter = AttemptCounter() + let logger = MemoryLogger() + let client = makeClient(counter: counter, logger: logger) + + await expectInvalidRequest( + client + .get("/users") + .intercept(MethodReplacingInterceptor(method: "PATCH")), + counter: counter, + logger: logger + ) + } + + @Test("request interceptor의 POST에서 GET 변경은 전송 전에 차단한다") + func requestInterceptorChangingPostToGetIsRejected() async { + let counter = AttemptCounter() + let logger = MemoryLogger() + let client = makeClient(counter: counter, logger: logger) + + await expectInvalidRequest( + client + .post("/users") + .intercept(MethodReplacingInterceptor(method: "GET")), + counter: counter, + logger: logger + ) + } + + private func makeClient( + counter: AttemptCounter, + logger: MemoryLogger, + interceptors: [any NXHTTPInterceptor] = [] + ) -> NXAPIClient { + NXAPIClient( + configuration: NXClientConfiguration( + baseURL: URL(string: "https://example.com")!, + transport: ClosureTransport { _ in + _ = await counter.increment() + return makeRawResponse(statusCode: 200, body: #"{"id":1,"name":"unexpected"}"#) + }, + logger: logger, + interceptors: interceptors, + cache: .memory(ttl: 10) + ) + ) + } + + private func expectInvalidRequest( + _ builder: NXRequestBuilder, + counter: AttemptCounter, + logger: MemoryLogger + ) async { + await #expect { + let _: UserDTO = try await builder.send(as: UserDTO.self) + } throws: { error in + guard case NXError.invalidRequest = error else { + return false + } + return true + } + + #expect(await counter.value() == 0) + #expect(await logger.allEvents().isEmpty) + } +} + +private struct MethodReplacingInterceptor: NXHTTPInterceptor { + let method: String? + + func intercept( + context: NXRequestExecutionContext, + next: @escaping @Sendable (NXRequestExecutionContext) async throws -> NXRawResponse + ) async throws -> NXRawResponse { + var request = context.request + request.httpMethod = method + return try await next(context.replacingRequest(request)) + } +} diff --git a/Tests/NexaTests/NXRequestBodyExecutionAPITests.swift b/Tests/NXRequestBodyExecutionAPITests.swift similarity index 100% rename from Tests/NexaTests/NXRequestBodyExecutionAPITests.swift rename to Tests/NXRequestBodyExecutionAPITests.swift diff --git a/Tests/NexaTests/NXRequestBuildLogicTests.swift b/Tests/NXRequestBuildLogicTests.swift similarity index 100% rename from Tests/NexaTests/NXRequestBuildLogicTests.swift rename to Tests/NXRequestBuildLogicTests.swift diff --git a/Tests/NexaTests/NXResponseCacheInterceptorTests.swift b/Tests/NXResponseCacheInterceptorTests.swift similarity index 100% rename from Tests/NexaTests/NXResponseCacheInterceptorTests.swift rename to Tests/NXResponseCacheInterceptorTests.swift diff --git a/Tests/NexaTests/NXResponsePipelineTests.swift b/Tests/NXResponsePipelineTests.swift similarity index 100% rename from Tests/NexaTests/NXResponsePipelineTests.swift rename to Tests/NXResponsePipelineTests.swift diff --git a/Tests/NexaTests/NXRetryAfterParsingTests.swift b/Tests/NXRetryAfterParsingTests.swift similarity index 100% rename from Tests/NexaTests/NXRetryAfterParsingTests.swift rename to Tests/NXRetryAfterParsingTests.swift diff --git a/Tests/NXRetryBuilderAPITests.swift b/Tests/NXRetryBuilderAPITests.swift new file mode 100644 index 0000000..622e56b --- /dev/null +++ b/Tests/NXRetryBuilderAPITests.swift @@ -0,0 +1,236 @@ +// +// NXRetryBuilderAPITests.swift +// Nexa +// +// Created by opfic on 8/23/26. +// + +import Foundation +import Testing +@testable import Nexa + +@Suite("재시도 builder API 테스트") +struct NXRetryBuilderAPITests { + @Test("기본 maxAttempts는 GET 요청을 세 번 실행한다") + func defaultMaximumAttemptsRetriesGetThreeTimes() async throws { + let counter = AttemptCounter() + let client = makeClient { _ in + let attemptNumber = await counter.increment() + + if attemptNumber < 3 { + throw URLError(.timedOut) + } + + return makeRawResponse(statusCode: 200, body: #"{"id":3,"name":"retry"}"#) + } + + let user = try await client + .get("/users") + .retry() + .send(as: UserDTO.self) + + #expect(user == UserDTO(id: 3, name: "retry")) + #expect(await counter.value() == 3) + } + + @Test("typed builder의 기본 maxAttempts는 세 번이다") + func typedBuilderDefaultMaximumAttemptsIsThree() { + let client = makeClient { _ in + makeRawResponse(statusCode: 200, body: #"{"id":1,"name":"typed"}"#) + } + let builder = client + .request(RetryingGetEndpoint()) + .retry() + + #expect(builder.retryPolicy?.maxAttempts == 3) + } + + @Test("allowing은 기본 GET 재시도를 유지한다") + func allowingPreservesDefaultGetRetry() async throws { + let counter = AttemptCounter() + let client = makeClient { _ in + let attemptNumber = await counter.increment() + + if attemptNumber < 3 { + throw URLError(.timedOut) + } + + return makeRawResponse(statusCode: 200, body: #"{"id":3,"name":"retry"}"#) + } + + let user = try await client + .get("/users") + .retry(maxAttempts: 3, allowing: [.post]) + .send(as: UserDTO.self) + + #expect(user == UserDTO(id: 3, name: "retry")) + #expect(await counter.value() == 3) + } + + @Test("기본 POST 재시도는 실행하지 않는다") + func defaultPostDoesNotRetry() async { + let counter = AttemptCounter() + let client = makeClient { _ in + _ = await counter.increment() + throw URLError(.timedOut) + } + + await #expect { + let _: UserDTO = try await client + .post("/users") + .retry(maxAttempts: 3) + .send(as: UserDTO.self) + } throws: { error in + guard case NXError.timeout = error else { + return false + } + return true + } + + #expect(await counter.value() == 1) + } + + @Test("maxAttempts 0은 기본 GET을 한 번만 실행한다") + func maximumAttemptsNormalizesToOne() async { + let counter = AttemptCounter() + let client = makeClient { _ in + _ = await counter.increment() + throw URLError(.timedOut) + } + + await #expect { + let _: UserDTO = try await client + .get("/users") + .retry(maxAttempts: 0) + .send(as: UserDTO.self) + } throws: { error in + guard case NXError.timeout = error else { + return false + } + return true + } + + #expect(await counter.value() == 1) + } + + @Test("allowing POST는 재시도를 허용한다") + func allowingPostRetriesUntilMaximumAttempts() async throws { + let counter = AttemptCounter() + let client = makeClient { _ in + let attemptNumber = await counter.increment() + + if attemptNumber < 3 { + throw URLError(.timedOut) + } + + return makeRawResponse(statusCode: 200, body: #"{"id":3,"name":"created"}"#) + } + + let user = try await client + .post("/users") + .retry(maxAttempts: 3, allowing: [.post]) + .send(as: UserDTO.self) + + #expect(user == UserDTO(id: 3, name: "created")) + #expect(await counter.value() == 3) + } + + @Test("typed builder는 모든 retry parameter를 내부 정책에 전달한다") + func typedBuilderForwardsRetryParameters() async throws { + let client = makeClient { _ in + makeRawResponse(statusCode: 200, body: #"{"id":1,"name":"typed"}"#) + } + let builder = client + .request(RetryingGetEndpoint()) + .retry( + maxAttempts: 0, + backoff: .exponential(base: 0.5, maxDelay: 2), + retryableStatusCodes: [418], + allowing: [.post], + maximumServerDelay: 0, + jitter: .full + ) + + guard let retryPolicy = builder.retryPolicy else { + Issue.record("retry policy missing") + return + } + + #expect(retryPolicy.maxAttempts == 1) + #expect(retryPolicy.backoff.delay(forAttempt: 3) == 2) + #expect(retryPolicy.retryableStatusCodes == [418]) + #expect(retryPolicy.allowedMethods == [.get, .head, .put, .delete, .options, .post]) + #expect(retryPolicy.maximumServerDelay == 0) + #expect(retryPolicy.jitter == .full) + } + + @Test("typed builder는 custom status 재시도를 실행한다") + func typedBuilderRetriesCustomStatus() async throws { + let counter = AttemptCounter() + let client = makeClient { _ in + let attemptNumber = await counter.increment() + + if attemptNumber == 1 { + return makeRawResponse(statusCode: 418, body: "{}") + } + + return makeRawResponse(statusCode: 200, body: #"{"id":2,"name":"typed"}"#) + } + + let user = try await client + .request(RetryingGetEndpoint()) + .retry(maxAttempts: 2, retryableStatusCodes: [418]) + .send() + + #expect(user == UserDTO(id: 2, name: "typed")) + #expect(await counter.value() == 2) + } + + @Test("endpoint configure는 typed retry builder를 유지한다") + func endpointConfigureUsesTypedRetryBuilder() async throws { + let counter = AttemptCounter() + let client = makeClient { _ in + let attemptNumber = await counter.increment() + + if attemptNumber == 1 { + throw URLError(.timedOut) + } + + return makeRawResponse(statusCode: 200, body: #"{"id":2,"name":"endpoint"}"#) + } + + let user = try await client.request(RetryingPostEndpoint()).send() + + #expect(user == UserDTO(id: 2, name: "endpoint")) + #expect(await counter.value() == 2) + } + + private func makeClient( + transport: @escaping @Sendable (URLRequest) async throws -> NXRawResponse + ) -> NXAPIClient { + NXAPIClient( + configuration: NXClientConfiguration( + baseURL: URL(string: "https://example.com")!, + transport: ClosureTransport(sendClosure: transport) + ) + ) + } +} + +private struct RetryingPostEndpoint: NXEndpoint { + typealias Response = UserDTO + + var method: NXHTTPMethod { .post } + var path: String { "/users" } + + func configure(_ builder: NXTypedRequestBuilder) -> NXTypedRequestBuilder { + builder.retry(maxAttempts: 2, allowing: [.post]) + } +} + +private struct RetryingGetEndpoint: NXEndpoint { + typealias Response = UserDTO + + var method: NXHTTPMethod { .get } + var path: String { "/users" } +} diff --git a/Tests/NexaTests/NXRetryDelayTests.swift b/Tests/NXRetryDelayTests.swift similarity index 100% rename from Tests/NexaTests/NXRetryDelayTests.swift rename to Tests/NXRetryDelayTests.swift diff --git a/Tests/NexaTests/NXRetryExecutionDependenciesTests.swift b/Tests/NXRetryExecutionDependenciesTests.swift similarity index 100% rename from Tests/NexaTests/NXRetryExecutionDependenciesTests.swift rename to Tests/NXRetryExecutionDependenciesTests.swift diff --git a/Tests/NexaTests/NXRetryExecutionTests.swift b/Tests/NXRetryExecutionTests.swift similarity index 100% rename from Tests/NexaTests/NXRetryExecutionTests.swift rename to Tests/NXRetryExecutionTests.swift diff --git a/Tests/NexaTests/NXRetryInterceptorTests.swift b/Tests/NXRetryInterceptorTests.swift similarity index 95% rename from Tests/NexaTests/NXRetryInterceptorTests.swift rename to Tests/NXRetryInterceptorTests.swift index 9d513d2..6d7090f 100644 --- a/Tests/NexaTests/NXRetryInterceptorTests.swift +++ b/Tests/NXRetryInterceptorTests.swift @@ -32,8 +32,7 @@ struct NXRetryInterceptorTests { @Test("명시 허용한 POST는 전송 오류 뒤 재시도한다") func optInMethodRetriesAfterTransportFailure() async { - var policy = NXRetryPolicy(maxAttempts: 2) - policy.retryableMethods.insert(.post) + let policy = NXRetryPolicy(maxAttempts: 2, allowing: [.post]) #expect(await attemptCount(method: .post, policy: policy) == 2) }