From 78d0d03ca5426017130d8a733e3fe8962d910f5d Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:50:10 +0900 Subject: [PATCH 1/6] =?UTF-8?q?chore:=20=ED=8F=B4=EB=8D=94=EB=A7=81=20?= =?UTF-8?q?=EA=B0=84=EC=86=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/rules/architecture.md | 8 ++++---- Examples/NexaClient/{NexaClient => }/ContentView.swift | 0 Examples/NexaClient/NexaClient.xcodeproj/project.pbxproj | 6 +++--- Examples/NexaClient/{NexaClient => }/NexaClientApp.swift | 0 .../{NexaClient => }/NexaIntegrationPreview.swift | 0 Package.swift | 8 ++++++-- Sources/{Nexa => }/Core/NXCache.swift | 0 Sources/{Nexa => }/Core/NXClientConfiguration.swift | 0 Sources/{Nexa => }/Core/NXError.swift | 0 Sources/{Nexa => }/Core/NXHTTPMethod.swift | 0 Sources/{Nexa => }/Core/NXLogging.swift | 0 Sources/{Nexa => }/Core/NXProtocols.swift | 0 Sources/{Nexa => }/Core/NXRawResponse.swift | 0 Sources/{Nexa => }/Core/NXRequestBody.swift | 0 Sources/{Nexa => }/Core/NXRequestSpec.swift | 0 Sources/{Nexa => }/Core/NXRetryPolicy.swift | 0 Sources/{Nexa => }/Core/NXValidationPolicy.swift | 0 Sources/{Nexa => }/Nexa.docc/Nexa.md | 0 Sources/{Nexa => }/Public/NXAPIClient.swift | 0 Sources/{Nexa => }/Public/NXEndpoint.swift | 0 Sources/{Nexa => }/Public/NXHTTPInterceptor.swift | 0 Sources/{Nexa => }/Public/NXRequestBuilder.swift | 0 Sources/{Nexa => }/Public/NXTypedRequestBuilder.swift | 0 Sources/{Nexa => }/Runtime/NXAuthInterceptor.swift | 0 Sources/{Nexa => }/Runtime/NXInterceptorChain.swift | 0 Sources/{Nexa => }/Runtime/NXLoggerInterceptor.swift | 0 Sources/{Nexa => }/Runtime/NXRequestAssembler.swift | 0 Sources/{Nexa => }/Runtime/NXRequestCacheKey.swift | 0 Sources/{Nexa => }/Runtime/NXRequestExecutor.swift | 0 .../{Nexa => }/Runtime/NXResponseCacheInterceptor.swift | 0 Sources/{Nexa => }/Runtime/NXResponseCacheStore.swift | 0 Sources/{Nexa => }/Runtime/NXResponsePipeline.swift | 0 .../{Nexa => }/Runtime/NXRetryExecutionDependencies.swift | 0 Sources/{Nexa => }/Runtime/NXRetryInterceptor.swift | 0 Sources/{Nexa => }/Runtime/NXURLSessionTransport.swift | 0 Tests/{NexaTests => }/Fixtures/NXNetworkTestSupport.swift | 0 .../NXClientConfigurationProtocolTests.swift | 0 Tests/{NexaTests => }/NXCoreModelPolicyTests.swift | 0 Tests/{NexaTests => }/NXDeclarativeRequestDSLTests.swift | 0 Tests/{NexaTests => }/NXInterceptorChainTests.swift | 0 .../{NexaTests => }/NXRequestBodyExecutionAPITests.swift | 0 Tests/{NexaTests => }/NXRequestBuildLogicTests.swift | 0 .../{NexaTests => }/NXResponseCacheInterceptorTests.swift | 0 Tests/{NexaTests => }/NXResponsePipelineTests.swift | 0 Tests/{NexaTests => }/NXRetryAfterParsingTests.swift | 0 Tests/{NexaTests => }/NXRetryDelayTests.swift | 0 .../NXRetryExecutionDependenciesTests.swift | 0 Tests/{NexaTests => }/NXRetryExecutionTests.swift | 0 Tests/{NexaTests => }/NXRetryInterceptorTests.swift | 0 49 files changed, 13 insertions(+), 9 deletions(-) rename Examples/NexaClient/{NexaClient => }/ContentView.swift (100%) rename Examples/NexaClient/{NexaClient => }/NexaClientApp.swift (100%) rename Examples/NexaClient/{NexaClient => }/NexaIntegrationPreview.swift (100%) rename Sources/{Nexa => }/Core/NXCache.swift (100%) rename Sources/{Nexa => }/Core/NXClientConfiguration.swift (100%) rename Sources/{Nexa => }/Core/NXError.swift (100%) rename Sources/{Nexa => }/Core/NXHTTPMethod.swift (100%) rename Sources/{Nexa => }/Core/NXLogging.swift (100%) rename Sources/{Nexa => }/Core/NXProtocols.swift (100%) rename Sources/{Nexa => }/Core/NXRawResponse.swift (100%) rename Sources/{Nexa => }/Core/NXRequestBody.swift (100%) rename Sources/{Nexa => }/Core/NXRequestSpec.swift (100%) rename Sources/{Nexa => }/Core/NXRetryPolicy.swift (100%) rename Sources/{Nexa => }/Core/NXValidationPolicy.swift (100%) rename Sources/{Nexa => }/Nexa.docc/Nexa.md (100%) rename Sources/{Nexa => }/Public/NXAPIClient.swift (100%) rename Sources/{Nexa => }/Public/NXEndpoint.swift (100%) rename Sources/{Nexa => }/Public/NXHTTPInterceptor.swift (100%) rename Sources/{Nexa => }/Public/NXRequestBuilder.swift (100%) rename Sources/{Nexa => }/Public/NXTypedRequestBuilder.swift (100%) rename Sources/{Nexa => }/Runtime/NXAuthInterceptor.swift (100%) rename Sources/{Nexa => }/Runtime/NXInterceptorChain.swift (100%) rename Sources/{Nexa => }/Runtime/NXLoggerInterceptor.swift (100%) rename Sources/{Nexa => }/Runtime/NXRequestAssembler.swift (100%) rename Sources/{Nexa => }/Runtime/NXRequestCacheKey.swift (100%) rename Sources/{Nexa => }/Runtime/NXRequestExecutor.swift (100%) rename Sources/{Nexa => }/Runtime/NXResponseCacheInterceptor.swift (100%) rename Sources/{Nexa => }/Runtime/NXResponseCacheStore.swift (100%) rename Sources/{Nexa => }/Runtime/NXResponsePipeline.swift (100%) rename Sources/{Nexa => }/Runtime/NXRetryExecutionDependencies.swift (100%) rename Sources/{Nexa => }/Runtime/NXRetryInterceptor.swift (100%) rename Sources/{Nexa => }/Runtime/NXURLSessionTransport.swift (100%) rename Tests/{NexaTests => }/Fixtures/NXNetworkTestSupport.swift (100%) rename Tests/{NexaTests => }/NXClientConfigurationProtocolTests.swift (100%) rename Tests/{NexaTests => }/NXCoreModelPolicyTests.swift (100%) rename Tests/{NexaTests => }/NXDeclarativeRequestDSLTests.swift (100%) rename Tests/{NexaTests => }/NXInterceptorChainTests.swift (100%) rename Tests/{NexaTests => }/NXRequestBodyExecutionAPITests.swift (100%) rename Tests/{NexaTests => }/NXRequestBuildLogicTests.swift (100%) rename Tests/{NexaTests => }/NXResponseCacheInterceptorTests.swift (100%) rename Tests/{NexaTests => }/NXResponsePipelineTests.swift (100%) rename Tests/{NexaTests => }/NXRetryAfterParsingTests.swift (100%) rename Tests/{NexaTests => }/NXRetryDelayTests.swift (100%) rename Tests/{NexaTests => }/NXRetryExecutionDependenciesTests.swift (100%) rename Tests/{NexaTests => }/NXRetryExecutionTests.swift (100%) rename Tests/{NexaTests => }/NXRetryInterceptorTests.swift (100%) 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/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/Nexa/Core/NXRetryPolicy.swift b/Sources/Core/NXRetryPolicy.swift similarity index 100% rename from Sources/Nexa/Core/NXRetryPolicy.swift rename to Sources/Core/NXRetryPolicy.swift 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 100% rename from Sources/Nexa/Nexa.docc/Nexa.md rename to Sources/Nexa.docc/Nexa.md 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 100% rename from Sources/Nexa/Public/NXHTTPInterceptor.swift rename to Sources/Public/NXHTTPInterceptor.swift diff --git a/Sources/Nexa/Public/NXRequestBuilder.swift b/Sources/Public/NXRequestBuilder.swift similarity index 100% rename from Sources/Nexa/Public/NXRequestBuilder.swift rename to Sources/Public/NXRequestBuilder.swift diff --git a/Sources/Nexa/Public/NXTypedRequestBuilder.swift b/Sources/Public/NXTypedRequestBuilder.swift similarity index 100% rename from Sources/Nexa/Public/NXTypedRequestBuilder.swift rename to Sources/Public/NXTypedRequestBuilder.swift 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 100% rename from Sources/Nexa/Runtime/NXInterceptorChain.swift rename to Sources/Runtime/NXInterceptorChain.swift 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 100% rename from Sources/Nexa/Runtime/NXRetryInterceptor.swift rename to Sources/Runtime/NXRetryInterceptor.swift 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 100% rename from Tests/NexaTests/NXCoreModelPolicyTests.swift rename to Tests/NXCoreModelPolicyTests.swift 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 100% rename from Tests/NexaTests/NXInterceptorChainTests.swift rename to Tests/NXInterceptorChainTests.swift 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/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 100% rename from Tests/NexaTests/NXRetryInterceptorTests.swift rename to Tests/NXRetryInterceptorTests.swift From f532664e531be69e99de44f89d35d503fda88ac0 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:20:19 +0900 Subject: [PATCH 2/6] =?UTF-8?q?refactor:=20NXRetryBackoff=EC=99=80=20?= =?UTF-8?q?=EB=82=B4=EB=B6=80=20RetryPolicy=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/Core/NXRequestSpec.swift | 2 +- Sources/Core/NXRetryBackoff.swift | 27 ++++++++++++ Sources/Core/NXRetryJitter.swift | 14 ++++++ Sources/Core/RetryPolicy.swift | 54 ++++++++++++++++++++++++ Sources/Public/NXRequestBuilder.swift | 2 +- Sources/Runtime/NXRetryInterceptor.swift | 6 +-- Tests/NXCoreModelPolicyTests.swift | 12 +++--- Tests/NXRetryAfterParsingTests.swift | 18 ++++---- Tests/NXRetryDelayTests.swift | 10 ++--- Tests/NXRetryExecutionTests.swift | 8 ++-- Tests/NXRetryInterceptorTests.swift | 9 ++-- 11 files changed, 128 insertions(+), 34 deletions(-) create mode 100644 Sources/Core/NXRetryBackoff.swift create mode 100644 Sources/Core/NXRetryJitter.swift create mode 100644 Sources/Core/RetryPolicy.swift diff --git a/Sources/Core/NXRequestSpec.swift b/Sources/Core/NXRequestSpec.swift index 20b277c..3641e5f 100644 --- a/Sources/Core/NXRequestSpec.swift +++ b/Sources/Core/NXRequestSpec.swift @@ -20,7 +20,7 @@ struct RequestSpec: Sendable { var body: NXRequestBody? var timeout: TimeInterval? var authRequirement: NXAuthRequirement - var retryPolicy: NXRetryPolicy? + var retryPolicy: RetryPolicy? var validationPolicy: NXValidationPolicy var requestInterceptors: [any NXHTTPInterceptor] var userInfo: [String: String] 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/RetryPolicy.swift b/Sources/Core/RetryPolicy.swift new file mode 100644 index 0000000..2ad443b --- /dev/null +++ b/Sources/Core/RetryPolicy.swift @@ -0,0 +1,54 @@ +// +// RetryPolicy.swift +// Nexa +// +// Created by opfic on 8/23/26. +// + +import Foundation + +struct RetryPolicy: 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 + } + + init(_ policy: NXRetryPolicy) { + maxAttempts = policy.maxAttempts + backoff = switch policy.backoff { + case let .fixed(seconds): + .fixed(seconds) + case let .exponential(base, maxDelay): + .exponential(base: base, maxDelay: maxDelay) + } + retryableStatusCodes = policy.retryableStatusCodes + allowedMethods = policy.retryableMethods + maximumServerDelay = policy.maximumServerDelay + jitter = switch policy.jitter { + case .none: + .none + case .full: + .full + } + } +} diff --git a/Sources/Public/NXRequestBuilder.swift b/Sources/Public/NXRequestBuilder.swift index 505e9cc..a6d740c 100644 --- a/Sources/Public/NXRequestBuilder.swift +++ b/Sources/Public/NXRequestBuilder.swift @@ -150,7 +150,7 @@ public struct NXRequestBuilder: Sendable { /// - Returns: Updated request builder. public func retry(_ policy: NXRetryPolicy) -> Self { modifying { requestSpec in - requestSpec.retryPolicy = policy + requestSpec.retryPolicy = RetryPolicy(policy) } } diff --git a/Sources/Runtime/NXRetryInterceptor.swift b/Sources/Runtime/NXRetryInterceptor.swift index 9b44e9a..db2c9dc 100644 --- a/Sources/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) } @@ -96,7 +96,7 @@ struct NXRetryInterceptor: NXHTTPInterceptor { private func retryDelay( after response: NXRawResponse, - retryPolicy: NXRetryPolicy, + retryPolicy: RetryPolicy, attemptNumber: Int ) -> TimeInterval { if let serverDelay = serverDelay(from: response.response) { @@ -234,7 +234,7 @@ struct NXRetryInterceptor: NXHTTPInterceptor { } private func localDelay( - retryPolicy: NXRetryPolicy, + retryPolicy: RetryPolicy, attemptNumber: Int ) -> TimeInterval { let delay = retryPolicy.backoff.delay(forAttempt: attemptNumber) diff --git a/Tests/NXCoreModelPolicyTests.swift b/Tests/NXCoreModelPolicyTests.swift index a793bf6..dd1508f 100644 --- a/Tests/NXCoreModelPolicyTests.swift +++ b/Tests/NXCoreModelPolicyTests.swift @@ -46,14 +46,14 @@ struct NXCoreModelPolicyTests { @Test("재시도 정책이 최소 시도 횟수와 backoff 지연 계산을 보장한다") func retryPolicyNormalizesAttemptsAndBackoff() { - let retryPolicy = NXRetryPolicy(maxAttempts: 0, backoff: .fixed(-1)) + let retryPolicy = RetryPolicy(maxAttempts: 0, backoff: .fixed(-1)) #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) @@ -62,10 +62,10 @@ struct NXCoreModelPolicyTests { @Test("재시도 정책이 멱등 method와 서버 지연 기본값을 제공한다") func retryPolicyDefaultRetrySemantics() { - let defaultRetryPolicy = NXRetryPolicy(maxAttempts: 2) - let retryPolicy = NXRetryPolicy(maxAttempts: 2, maximumServerDelay: -1) + let defaultRetryPolicy = RetryPolicy(maxAttempts: 2) + let retryPolicy = RetryPolicy(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/NXRetryAfterParsingTests.swift b/Tests/NXRetryAfterParsingTests.swift index f9db286..ccef7e7 100644 --- a/Tests/NXRetryAfterParsingTests.swift +++ b/Tests/NXRetryAfterParsingTests.swift @@ -20,7 +20,7 @@ struct NXRetryAfterParsingTests { let serverDate = calendar.date( from: DateComponents(year: 2060, month: 11, day: 6, hour: 8, minute: 49, second: 37) )! - let policy = NXRetryPolicy( + let policy = RetryPolicy( maxAttempts: 2, maximumServerDelay: .greatestFiniteMagnitude ) @@ -38,7 +38,7 @@ struct NXRetryAfterParsingTests { 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)), + policy: RetryPolicy(maxAttempts: 2, backoff: .fixed(4)), now: Date.distantPast ) @@ -49,7 +49,7 @@ struct NXRetryAfterParsingTests { func pastHTTPDateUsesZeroDelay() async throws { let delay = try await retryDelay( header: "Thu, 01 Jan 1970 00:00:00 GMT", - policy: NXRetryPolicy(maxAttempts: 2), + policy: RetryPolicy(maxAttempts: 2), now: Date(timeIntervalSince1970: 1) ) @@ -60,7 +60,7 @@ struct NXRetryAfterParsingTests { func negativeRetryAfterUsesLocalBackoff() async throws { let delay = try await retryDelay( header: "-1", - policy: NXRetryPolicy(maxAttempts: 2, backoff: .fixed(4)), + policy: RetryPolicy(maxAttempts: 2, backoff: .fixed(4)), now: Date.distantPast ) @@ -71,7 +71,7 @@ struct NXRetryAfterParsingTests { func signedRetryAfterUsesLocalBackoff() async throws { let delay = try await retryDelay( header: "+1", - policy: NXRetryPolicy(maxAttempts: 2, backoff: .fixed(4)), + policy: RetryPolicy(maxAttempts: 2, backoff: .fixed(4)), now: Date.distantPast ) @@ -82,7 +82,7 @@ struct NXRetryAfterParsingTests { func overflowingRetryAfterUsesServerDelayCap() async throws { let delay = try await retryDelay( header: String(repeating: "9", count: 100), - policy: NXRetryPolicy(maxAttempts: 2, maximumServerDelay: 60), + policy: RetryPolicy(maxAttempts: 2, maximumServerDelay: 60), now: Date.distantPast ) @@ -93,7 +93,7 @@ struct NXRetryAfterParsingTests { func retryAfterOutsideSupportedStatusUsesLocalBackoff() async throws { let delay = try await retryDelay( header: "120", - policy: NXRetryPolicy(maxAttempts: 2, backoff: .fixed(4)), + policy: RetryPolicy(maxAttempts: 2, backoff: .fixed(4)), now: Date.distantPast, statusCode: 500 ) @@ -109,7 +109,7 @@ struct NXRetryAfterParsingTests { 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)) + specification.retryPolicy = RetryPolicy(maxAttempts: 2, backoff: .fixed(4)) let context = NXRequestExecutionContext( request: request, requestIdentifier: specification.requestIdentifier, @@ -139,7 +139,7 @@ struct NXRetryAfterParsingTests { private func retryDelay( header: String, - policy: NXRetryPolicy, + policy: RetryPolicy, now: Date, statusCode: Int = 429 ) async throws -> TimeInterval { diff --git a/Tests/NXRetryDelayTests.swift b/Tests/NXRetryDelayTests.swift index 711c7a0..4a0c25c 100644 --- a/Tests/NXRetryDelayTests.swift +++ b/Tests/NXRetryDelayTests.swift @@ -15,7 +15,7 @@ struct NXRetryDelayTests { func retryAfterSecondsUsesServerDelayCap(statusCode: Int) async throws { let recorder = RetryExecutionRecorder() let logger = MemoryLogger() - let policy = NXRetryPolicy( + let policy = RetryPolicy( maxAttempts: 2, maximumServerDelay: 60, jitter: .full @@ -42,7 +42,7 @@ struct NXRetryDelayTests { @Test("Retry-After HTTP-date는 주입된 현재 시각을 기준으로 계산한다") func retryAfterHTTPDateUsesInjectedCurrentTime() async throws { let recorder = RetryExecutionRecorder() - let policy = NXRetryPolicy(maxAttempts: 2, maximumServerDelay: 180) + let policy = RetryPolicy(maxAttempts: 2, maximumServerDelay: 180) let referenceDate = Date(timeIntervalSince1970: 0) _ = try await execute( @@ -64,7 +64,7 @@ struct NXRetryDelayTests { @Test("잘못된 Retry-After는 local backoff로 복귀한다") func invalidRetryAfterUsesLocalBackoff() async throws { let recorder = RetryExecutionRecorder() - let policy = NXRetryPolicy(maxAttempts: 2, backoff: .fixed(4)) + let policy = RetryPolicy(maxAttempts: 2, backoff: .fixed(4)) _ = try await execute( policy: policy, @@ -85,7 +85,7 @@ struct NXRetryDelayTests { @Test("local jitter는 주입된 무작위 값으로 계산한다") func localJitterUsesInjectedRandomValue() async throws { let recorder = RetryExecutionRecorder() - let policy = NXRetryPolicy(maxAttempts: 2, backoff: .fixed(4), jitter: .full) + let policy = RetryPolicy(maxAttempts: 2, backoff: .fixed(4), jitter: .full) _ = try await execute( policy: policy, @@ -104,7 +104,7 @@ struct NXRetryDelayTests { } private func execute( - policy: NXRetryPolicy, + policy: RetryPolicy, responses: [NXRawResponse], logger: any NXLogger = NXNoopLogger(), dependencies: NXRetryExecutionDependencies diff --git a/Tests/NXRetryExecutionTests.swift b/Tests/NXRetryExecutionTests.swift index 0f6c518..4345d66 100644 --- a/Tests/NXRetryExecutionTests.swift +++ b/Tests/NXRetryExecutionTests.swift @@ -30,7 +30,7 @@ struct NXRetryExecutionTests { _ = try await executeRaw( configuration: configuration, - policy: NXRetryPolicy(maxAttempts: 2), + policy: RetryPolicy(maxAttempts: 2), dependencies: noDelayDependencies() ) @@ -62,7 +62,7 @@ struct NXRetryExecutionTests { _ = try await executeRaw( configuration: configuration, - policy: NXRetryPolicy(maxAttempts: 2), + policy: RetryPolicy(maxAttempts: 2), dependencies: noDelayDependencies() ) @@ -87,7 +87,7 @@ struct NXRetryExecutionTests { throw URLError(.timedOut) } ) - let policy = NXRetryPolicy(maxAttempts: 2, backoff: .fixed(1)) + let policy = RetryPolicy(maxAttempts: 2, backoff: .fixed(1)) let task = Task { try await executeRaw( configuration: configuration, @@ -125,7 +125,7 @@ struct NXRetryExecutionTests { private func executeRaw( configuration: NXClientConfiguration, - policy: NXRetryPolicy, + policy: RetryPolicy, dependencies: NXRetryExecutionDependencies ) async throws -> NXRawResponse { var specification = RequestSpec(method: .get, path: "/users") diff --git a/Tests/NXRetryInterceptorTests.swift b/Tests/NXRetryInterceptorTests.swift index 9d513d2..77334b7 100644 --- a/Tests/NXRetryInterceptorTests.swift +++ b/Tests/NXRetryInterceptorTests.swift @@ -19,7 +19,7 @@ struct NXRetryInterceptorTests { .options ]) func idempotentMethodsRetryAfterTransportFailure(method: NXHTTPMethod) async { - #expect(await attemptCount(method: method, policy: NXRetryPolicy(maxAttempts: 2)) == 2) + #expect(await attemptCount(method: method, policy: RetryPolicy(maxAttempts: 2)) == 2) } @Test("POST와 PATCH는 명시 허용 전 재시도하지 않는다", arguments: [ @@ -27,18 +27,17 @@ struct NXRetryInterceptorTests { .patch ]) func nonIdempotentMethodsDoNotRetryByDefault(method: NXHTTPMethod) async { - #expect(await attemptCount(method: method, policy: NXRetryPolicy(maxAttempts: 2)) == 1) + #expect(await attemptCount(method: method, policy: RetryPolicy(maxAttempts: 2)) == 1) } @Test("명시 허용한 POST는 전송 오류 뒤 재시도한다") func optInMethodRetriesAfterTransportFailure() async { - var policy = NXRetryPolicy(maxAttempts: 2) - policy.retryableMethods.insert(.post) + let policy = RetryPolicy(maxAttempts: 2, allowing: [.post]) #expect(await attemptCount(method: .post, policy: policy) == 2) } - private func attemptCount(method: NXHTTPMethod, policy: NXRetryPolicy) async -> Int { + private func attemptCount(method: NXHTTPMethod, policy: RetryPolicy) async -> Int { let counter = AttemptCounter() var request = URLRequest(url: URL(string: "https://example.com/users")!) request.httpMethod = method.rawValue From e787d3e9b48916fdf42984dea3ac323aefc7a5f0 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:21:05 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EB=A7=A4=EA=B0=9C=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EA=B8=B0=EB=B0=98=20retry=20builder=20API=20?= =?UTF-8?q?=EC=A0=9C=EA=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/Core/NXRequestSpec.swift | 2 +- Sources/Core/NXRetryPolicy.swift | 81 ++-------- Sources/Core/RetryPolicy.swift | 54 ------- Sources/Public/NXRequestBuilder.swift | 32 +++- Sources/Public/NXTypedRequestBuilder.swift | 32 +++- Sources/Runtime/NXRetryInterceptor.swift | 4 +- Tests/NXCoreModelPolicyTests.swift | 6 +- Tests/NXInterceptorChainTests.swift | 2 +- Tests/NXRetryAfterParsingTests.swift | 18 +-- Tests/NXRetryBuilderAPITests.swift | 179 +++++++++++++++++++++ Tests/NXRetryDelayTests.swift | 10 +- Tests/NXRetryExecutionTests.swift | 8 +- Tests/NXRetryInterceptorTests.swift | 8 +- 13 files changed, 279 insertions(+), 157 deletions(-) delete mode 100644 Sources/Core/RetryPolicy.swift create mode 100644 Tests/NXRetryBuilderAPITests.swift diff --git a/Sources/Core/NXRequestSpec.swift b/Sources/Core/NXRequestSpec.swift index 3641e5f..20b277c 100644 --- a/Sources/Core/NXRequestSpec.swift +++ b/Sources/Core/NXRequestSpec.swift @@ -20,7 +20,7 @@ struct RequestSpec: Sendable { var body: NXRequestBody? var timeout: TimeInterval? var authRequirement: NXAuthRequirement - var retryPolicy: RetryPolicy? + var retryPolicy: NXRetryPolicy? var validationPolicy: NXValidationPolicy var requestInterceptors: [any NXHTTPInterceptor] var userInfo: [String: String] diff --git a/Sources/Core/NXRetryPolicy.swift b/Sources/Core/NXRetryPolicy.swift index 7a023e9..cc2e8c2 100644 --- a/Sources/Core/NXRetryPolicy.swift +++ b/Sources/Core/NXRetryPolicy.swift @@ -2,84 +2,33 @@ // NXRetryPolicy.swift // Nexa // -// Created by 최윤진 on 4/12/26. +// Created by opfic on 8/23/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) +struct NXRetryPolicy: Sendable { + let maxAttempts: Int + let backoff: NXRetryBackoff + let retryableStatusCodes: Set + let allowedMethods: Set + let maximumServerDelay: TimeInterval + let jitter: NXRetryJitter - 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( + init( maxAttempts: Int, - backoff: Backoff = .fixed(0), + backoff: NXRetryBackoff = .fixed(0), retryableStatusCodes: Set = [408, 429, 500, 502, 503, 504], - retryableMethods: Set = [.get, .head, .put, .delete, .options], + allowing: Set = [], maximumServerDelay: TimeInterval = 60, - jitter: Jitter = .none + jitter: NXRetryJitter = .none ) { self.maxAttempts = max(1, maxAttempts) self.backoff = backoff self.retryableStatusCodes = retryableStatusCodes - self.retryableMethods = retryableMethods + 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/Core/RetryPolicy.swift b/Sources/Core/RetryPolicy.swift deleted file mode 100644 index 2ad443b..0000000 --- a/Sources/Core/RetryPolicy.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// RetryPolicy.swift -// Nexa -// -// Created by opfic on 8/23/26. -// - -import Foundation - -struct RetryPolicy: 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 - } - - init(_ policy: NXRetryPolicy) { - maxAttempts = policy.maxAttempts - backoff = switch policy.backoff { - case let .fixed(seconds): - .fixed(seconds) - case let .exponential(base, maxDelay): - .exponential(base: base, maxDelay: maxDelay) - } - retryableStatusCodes = policy.retryableStatusCodes - allowedMethods = policy.retryableMethods - maximumServerDelay = policy.maximumServerDelay - jitter = switch policy.jitter { - case .none: - .none - case .full: - .full - } - } -} diff --git a/Sources/Public/NXRequestBuilder.swift b/Sources/Public/NXRequestBuilder.swift index a6d740c..7296837 100644 --- a/Sources/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 = 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/Public/NXTypedRequestBuilder.swift b/Sources/Public/NXTypedRequestBuilder.swift index 1c5d11f..861bf87 100644 --- a/Sources/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/Runtime/NXRetryInterceptor.swift b/Sources/Runtime/NXRetryInterceptor.swift index db2c9dc..8a7de88 100644 --- a/Sources/Runtime/NXRetryInterceptor.swift +++ b/Sources/Runtime/NXRetryInterceptor.swift @@ -96,7 +96,7 @@ struct NXRetryInterceptor: NXHTTPInterceptor { private func retryDelay( after response: NXRawResponse, - retryPolicy: RetryPolicy, + retryPolicy: NXRetryPolicy, attemptNumber: Int ) -> TimeInterval { if let serverDelay = serverDelay(from: response.response) { @@ -234,7 +234,7 @@ struct NXRetryInterceptor: NXHTTPInterceptor { } private func localDelay( - retryPolicy: RetryPolicy, + retryPolicy: NXRetryPolicy, attemptNumber: Int ) -> TimeInterval { let delay = retryPolicy.backoff.delay(forAttempt: attemptNumber) diff --git a/Tests/NXCoreModelPolicyTests.swift b/Tests/NXCoreModelPolicyTests.swift index dd1508f..e664a16 100644 --- a/Tests/NXCoreModelPolicyTests.swift +++ b/Tests/NXCoreModelPolicyTests.swift @@ -46,7 +46,7 @@ struct NXCoreModelPolicyTests { @Test("재시도 정책이 최소 시도 횟수와 backoff 지연 계산을 보장한다") func retryPolicyNormalizesAttemptsAndBackoff() { - let retryPolicy = RetryPolicy(maxAttempts: 0, backoff: .fixed(-1)) + let retryPolicy = NXRetryPolicy(maxAttempts: 0, backoff: .fixed(-1)) #expect(retryPolicy.maxAttempts == 1) @@ -62,8 +62,8 @@ struct NXCoreModelPolicyTests { @Test("재시도 정책이 멱등 method와 서버 지연 기본값을 제공한다") func retryPolicyDefaultRetrySemantics() { - let defaultRetryPolicy = RetryPolicy(maxAttempts: 2) - let retryPolicy = RetryPolicy(maxAttempts: 2, maximumServerDelay: -1) + let defaultRetryPolicy = NXRetryPolicy(maxAttempts: 2) + let retryPolicy = NXRetryPolicy(maxAttempts: 2, maximumServerDelay: -1) #expect(defaultRetryPolicy.allowedMethods == [.get, .head, .put, .delete, .options]) #expect(defaultRetryPolicy.maximumServerDelay == 60) diff --git a/Tests/NXInterceptorChainTests.swift b/Tests/NXInterceptorChainTests.swift index 92d3845..cfe595e 100644 --- a/Tests/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/NXRetryAfterParsingTests.swift b/Tests/NXRetryAfterParsingTests.swift index ccef7e7..f9db286 100644 --- a/Tests/NXRetryAfterParsingTests.swift +++ b/Tests/NXRetryAfterParsingTests.swift @@ -20,7 +20,7 @@ struct NXRetryAfterParsingTests { let serverDate = calendar.date( from: DateComponents(year: 2060, month: 11, day: 6, hour: 8, minute: 49, second: 37) )! - let policy = RetryPolicy( + let policy = NXRetryPolicy( maxAttempts: 2, maximumServerDelay: .greatestFiniteMagnitude ) @@ -38,7 +38,7 @@ struct NXRetryAfterParsingTests { func unsupportedTimezoneUsesLocalBackoff() async throws { let delay = try await retryDelay( header: "Thu, 01 Jan 1970 00:02:00 PST", - policy: RetryPolicy(maxAttempts: 2, backoff: .fixed(4)), + policy: NXRetryPolicy(maxAttempts: 2, backoff: .fixed(4)), now: Date.distantPast ) @@ -49,7 +49,7 @@ struct NXRetryAfterParsingTests { func pastHTTPDateUsesZeroDelay() async throws { let delay = try await retryDelay( header: "Thu, 01 Jan 1970 00:00:00 GMT", - policy: RetryPolicy(maxAttempts: 2), + policy: NXRetryPolicy(maxAttempts: 2), now: Date(timeIntervalSince1970: 1) ) @@ -60,7 +60,7 @@ struct NXRetryAfterParsingTests { func negativeRetryAfterUsesLocalBackoff() async throws { let delay = try await retryDelay( header: "-1", - policy: RetryPolicy(maxAttempts: 2, backoff: .fixed(4)), + policy: NXRetryPolicy(maxAttempts: 2, backoff: .fixed(4)), now: Date.distantPast ) @@ -71,7 +71,7 @@ struct NXRetryAfterParsingTests { func signedRetryAfterUsesLocalBackoff() async throws { let delay = try await retryDelay( header: "+1", - policy: RetryPolicy(maxAttempts: 2, backoff: .fixed(4)), + policy: NXRetryPolicy(maxAttempts: 2, backoff: .fixed(4)), now: Date.distantPast ) @@ -82,7 +82,7 @@ struct NXRetryAfterParsingTests { func overflowingRetryAfterUsesServerDelayCap() async throws { let delay = try await retryDelay( header: String(repeating: "9", count: 100), - policy: RetryPolicy(maxAttempts: 2, maximumServerDelay: 60), + policy: NXRetryPolicy(maxAttempts: 2, maximumServerDelay: 60), now: Date.distantPast ) @@ -93,7 +93,7 @@ struct NXRetryAfterParsingTests { func retryAfterOutsideSupportedStatusUsesLocalBackoff() async throws { let delay = try await retryDelay( header: "120", - policy: RetryPolicy(maxAttempts: 2, backoff: .fixed(4)), + policy: NXRetryPolicy(maxAttempts: 2, backoff: .fixed(4)), now: Date.distantPast, statusCode: 500 ) @@ -109,7 +109,7 @@ struct NXRetryAfterParsingTests { 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 = RetryPolicy(maxAttempts: 2, backoff: .fixed(4)) + specification.retryPolicy = NXRetryPolicy(maxAttempts: 2, backoff: .fixed(4)) let context = NXRequestExecutionContext( request: request, requestIdentifier: specification.requestIdentifier, @@ -139,7 +139,7 @@ struct NXRetryAfterParsingTests { private func retryDelay( header: String, - policy: RetryPolicy, + policy: NXRetryPolicy, now: Date, statusCode: Int = 429 ) async throws -> TimeInterval { diff --git a/Tests/NXRetryBuilderAPITests.swift b/Tests/NXRetryBuilderAPITests.swift new file mode 100644 index 0000000..9a881bc --- /dev/null +++ b/Tests/NXRetryBuilderAPITests.swift @@ -0,0 +1,179 @@ +// +// NXRetryBuilderAPITests.swift +// Nexa +// +// Created by opfic on 8/23/26. +// + +import Foundation +import Testing +@testable import Nexa + +@Suite("재시도 builder API 테스트") +struct NXRetryBuilderAPITests { + @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("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/NXRetryDelayTests.swift b/Tests/NXRetryDelayTests.swift index 4a0c25c..711c7a0 100644 --- a/Tests/NXRetryDelayTests.swift +++ b/Tests/NXRetryDelayTests.swift @@ -15,7 +15,7 @@ struct NXRetryDelayTests { func retryAfterSecondsUsesServerDelayCap(statusCode: Int) async throws { let recorder = RetryExecutionRecorder() let logger = MemoryLogger() - let policy = RetryPolicy( + let policy = NXRetryPolicy( maxAttempts: 2, maximumServerDelay: 60, jitter: .full @@ -42,7 +42,7 @@ struct NXRetryDelayTests { @Test("Retry-After HTTP-date는 주입된 현재 시각을 기준으로 계산한다") func retryAfterHTTPDateUsesInjectedCurrentTime() async throws { let recorder = RetryExecutionRecorder() - let policy = RetryPolicy(maxAttempts: 2, maximumServerDelay: 180) + let policy = NXRetryPolicy(maxAttempts: 2, maximumServerDelay: 180) let referenceDate = Date(timeIntervalSince1970: 0) _ = try await execute( @@ -64,7 +64,7 @@ struct NXRetryDelayTests { @Test("잘못된 Retry-After는 local backoff로 복귀한다") func invalidRetryAfterUsesLocalBackoff() async throws { let recorder = RetryExecutionRecorder() - let policy = RetryPolicy(maxAttempts: 2, backoff: .fixed(4)) + let policy = NXRetryPolicy(maxAttempts: 2, backoff: .fixed(4)) _ = try await execute( policy: policy, @@ -85,7 +85,7 @@ struct NXRetryDelayTests { @Test("local jitter는 주입된 무작위 값으로 계산한다") func localJitterUsesInjectedRandomValue() async throws { let recorder = RetryExecutionRecorder() - let policy = RetryPolicy(maxAttempts: 2, backoff: .fixed(4), jitter: .full) + let policy = NXRetryPolicy(maxAttempts: 2, backoff: .fixed(4), jitter: .full) _ = try await execute( policy: policy, @@ -104,7 +104,7 @@ struct NXRetryDelayTests { } private func execute( - policy: RetryPolicy, + policy: NXRetryPolicy, responses: [NXRawResponse], logger: any NXLogger = NXNoopLogger(), dependencies: NXRetryExecutionDependencies diff --git a/Tests/NXRetryExecutionTests.swift b/Tests/NXRetryExecutionTests.swift index 4345d66..0f6c518 100644 --- a/Tests/NXRetryExecutionTests.swift +++ b/Tests/NXRetryExecutionTests.swift @@ -30,7 +30,7 @@ struct NXRetryExecutionTests { _ = try await executeRaw( configuration: configuration, - policy: RetryPolicy(maxAttempts: 2), + policy: NXRetryPolicy(maxAttempts: 2), dependencies: noDelayDependencies() ) @@ -62,7 +62,7 @@ struct NXRetryExecutionTests { _ = try await executeRaw( configuration: configuration, - policy: RetryPolicy(maxAttempts: 2), + policy: NXRetryPolicy(maxAttempts: 2), dependencies: noDelayDependencies() ) @@ -87,7 +87,7 @@ struct NXRetryExecutionTests { throw URLError(.timedOut) } ) - let policy = RetryPolicy(maxAttempts: 2, backoff: .fixed(1)) + let policy = NXRetryPolicy(maxAttempts: 2, backoff: .fixed(1)) let task = Task { try await executeRaw( configuration: configuration, @@ -125,7 +125,7 @@ struct NXRetryExecutionTests { private func executeRaw( configuration: NXClientConfiguration, - policy: RetryPolicy, + policy: NXRetryPolicy, dependencies: NXRetryExecutionDependencies ) async throws -> NXRawResponse { var specification = RequestSpec(method: .get, path: "/users") diff --git a/Tests/NXRetryInterceptorTests.swift b/Tests/NXRetryInterceptorTests.swift index 77334b7..6d7090f 100644 --- a/Tests/NXRetryInterceptorTests.swift +++ b/Tests/NXRetryInterceptorTests.swift @@ -19,7 +19,7 @@ struct NXRetryInterceptorTests { .options ]) func idempotentMethodsRetryAfterTransportFailure(method: NXHTTPMethod) async { - #expect(await attemptCount(method: method, policy: RetryPolicy(maxAttempts: 2)) == 2) + #expect(await attemptCount(method: method, policy: NXRetryPolicy(maxAttempts: 2)) == 2) } @Test("POST와 PATCH는 명시 허용 전 재시도하지 않는다", arguments: [ @@ -27,17 +27,17 @@ struct NXRetryInterceptorTests { .patch ]) func nonIdempotentMethodsDoNotRetryByDefault(method: NXHTTPMethod) async { - #expect(await attemptCount(method: method, policy: RetryPolicy(maxAttempts: 2)) == 1) + #expect(await attemptCount(method: method, policy: NXRetryPolicy(maxAttempts: 2)) == 1) } @Test("명시 허용한 POST는 전송 오류 뒤 재시도한다") func optInMethodRetriesAfterTransportFailure() async { - let policy = RetryPolicy(maxAttempts: 2, allowing: [.post]) + let policy = NXRetryPolicy(maxAttempts: 2, allowing: [.post]) #expect(await attemptCount(method: .post, policy: policy) == 2) } - private func attemptCount(method: NXHTTPMethod, policy: RetryPolicy) async -> Int { + private func attemptCount(method: NXHTTPMethod, policy: NXRetryPolicy) async -> Int { let counter = AttemptCounter() var request = URLRequest(url: URL(string: "https://example.com/users")!) request.httpMethod = method.rawValue From a5ff798220cb3e9db42fe74310703b2667c9938e Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:21:19 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20interceptor=20method=20=EB=B6=88?= =?UTF-8?q?=EB=B3=80=20=EA=B3=84=EC=95=BD=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/Public/NXHTTPInterceptor.swift | 6 +- Sources/Runtime/NXInterceptorChain.swift | 4 + Tests/NXInterceptorMethodInvariantTests.swift | 110 ++++++++++++++++++ 3 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 Tests/NXInterceptorMethodInvariantTests.swift diff --git a/Sources/Public/NXHTTPInterceptor.swift b/Sources/Public/NXHTTPInterceptor.swift index da79a72..0fcfc74 100644 --- a/Sources/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/Runtime/NXInterceptorChain.swift b/Sources/Runtime/NXInterceptorChain.swift index 1020843..41799de 100644 --- a/Sources/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/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)) + } +} From 0e16e1458534590a8875424b9620b33b55fa3612 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:22:09 +0900 Subject: [PATCH 5/6] =?UTF-8?q?test:=20retry=20API=EC=99=80=20method=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=ED=9A=8C=EA=B7=80=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Tests/NXRetryBuilderAPITests.swift | 57 ++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/Tests/NXRetryBuilderAPITests.swift b/Tests/NXRetryBuilderAPITests.swift index 9a881bc..622e56b 100644 --- a/Tests/NXRetryBuilderAPITests.swift +++ b/Tests/NXRetryBuilderAPITests.swift @@ -11,6 +11,40 @@ import Testing @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() @@ -56,6 +90,29 @@ struct NXRetryBuilderAPITests { #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() From 84393e0c9778f6a64219019dd412b2ded19303cf Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:22:28 +0900 Subject: [PATCH 6/6] =?UTF-8?q?docs:=20Nexa=201.3=20retry=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=EA=B3=BC=20interceptor=20method=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=20=EC=95=88=EB=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.ko.md | 30 +++++++++++++++++++----------- README.md | 30 +++++++++++++++++++----------- Sources/Nexa.docc/Nexa.md | 24 +++++++++++++++--------- 3 files changed, 53 insertions(+), 31 deletions(-) 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.docc/Nexa.md b/Sources/Nexa.docc/Nexa.md index 11dde5d..bffb7ce 100644 --- a/Sources/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