Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .agents/rules/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions Examples/NexaClient/NexaClient.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<group>"; };
9398B6CF8522A7EC915B1B82 /* NexaIntegrationPreview.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NexaIntegrationPreview.swift; path = NexaClient/NexaIntegrationPreview.swift; sourceTree = "<group>"; };
C3CC707D1B1D4215893D0C24 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ContentView.swift; path = NexaClient/ContentView.swift; sourceTree = "<group>"; };
602E17E5451FC46F0B078998 /* NexaClientApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NexaClientApp.swift; path = NexaClientApp.swift; sourceTree = "<group>"; };
9398B6CF8522A7EC915B1B82 /* NexaIntegrationPreview.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NexaIntegrationPreview.swift; path = NexaIntegrationPreview.swift; sourceTree = "<group>"; };
C3CC707D1B1D4215893D0C24 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ContentView.swift; path = ContentView.swift; sourceTree = "<group>"; };
EBFB666B6AFCF02FCBA1B5A2 /* NexaClient.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; name = NexaClient.app; path = NexaClient.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */

Expand Down
8 changes: 6 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
30 changes: 19 additions & 11 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)` |
Expand Down Expand Up @@ -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 규칙을 함께 받지 않도록 구성합니다.
Expand Down
30 changes: 19 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)` |
Expand Down Expand Up @@ -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.
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
27 changes: 27 additions & 0 deletions Sources/Core/NXRetryBackoff.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
14 changes: 14 additions & 0 deletions Sources/Core/NXRetryJitter.swift
Original file line number Diff line number Diff line change
@@ -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
}
35 changes: 35 additions & 0 deletions Sources/Core/NXRetryPolicy.swift
Original file line number Diff line number Diff line change
@@ -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<Int>
let allowedMethods: Set<NXHTTPMethod>
let maximumServerDelay: TimeInterval
let jitter: NXRetryJitter

init(
maxAttempts: Int,
backoff: NXRetryBackoff = .fixed(0),
retryableStatusCodes: Set<Int> = [408, 429, 500, 502, 503, 504],
allowing: Set<NXHTTPMethod> = [],
maximumServerDelay: TimeInterval = 60,
jitter: NXRetryJitter = .none
) {
self.maxAttempts = max(1, maxAttempts)
self.backoff = backoff
self.retryableStatusCodes = retryableStatusCodes
var allowedMethods: Set<NXHTTPMethod> = [.get, .head, .put, .delete, .options]
allowedMethods.formUnion(allowing)
self.allowedMethods = allowedMethods
self.maximumServerDelay = max(0, maximumServerDelay)
self.jitter = jitter
}
}
24 changes: 15 additions & 9 deletions Sources/Nexa/Nexa.docc/Nexa.md → Sources/Nexa.docc/Nexa.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -108,7 +113,8 @@ let user = try await client.send(UserEndpoint(identifier: 42))
- ``NXRawResponse``
- ``NXError``
- ``NXValidationPolicy``
- ``NXRetryPolicy``
- ``NXRetryBackoff``
- ``NXRetryJitter``
- ``NXURLSessionTransport``

### Extension Points
Expand Down
Loading