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
9 changes: 0 additions & 9 deletions .gemini/styleguide.md

This file was deleted.

21 changes: 21 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Nexa는 `URLSession` 기반의 SwiftUI 스타일 선언형 네트워킹 라이
- [Endpoint API](#endpoint-api)
- [요청 전환](#요청-전환)
- [설정](#설정)
- [재시도 정책](#재시도-정책)
- [개발](#개발)
- [테스트](#테스트)

Expand Down Expand Up @@ -343,6 +344,26 @@ let client = NXAPIClient(configuration: configuration)
- 토큰 갱신 및 재시도 처리
- 스터빙 및 격리 테스트를 위한 커스텀 transport

## 재시도 정책

`NXRetryPolicy`는 설정한 재시도 상태 코드 또는 전송 오류가 발생하면 기본으로 `GET`, `HEAD`, `PUT`, `DELETE`, `OPTIONS`를 재시도합니다. 같은 요청을 반복해도 안전하게 처리하는 엔드포인트일 때만 `POST`, `PATCH`를 `retryableMethods`에 명시적으로 추가할 수 있습니다.

재시도 가능한 `429`, `503` 응답에서는 `Retry-After`의 초 단위와 HTTP-date 값을 처리합니다. 유효한 서버 값은 local backoff를 대체하고 기본 60초인 `maximumServerDelay`로 제한되며 `NXRetryLog`에 기록됩니다. `Jitter.full`은 local backoff에만 적용되고 서버가 지정한 지연을 줄이지 않습니다.

```swift
let retryPolicy = NXRetryPolicy(
maxAttempts: 3,
retryableMethods: [.get, .post],
maximumServerDelay: 30,
jitter: .none
)

let user = try await client
.post("/users")
.retry(retryPolicy)
.send(as: User.self)
```

## 개발

Nexa는 배포되는 package graph에서 SwiftLint를 분리하여 패키지 소비자가 maintainer용 lint 규칙을 함께 받지 않도록 구성합니다.
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Nexa is a SwiftUI-inspired declarative networking library built on `URLSession`.
- [Endpoint API](#endpoint-api)
- [Request Migration](#request-migration)
- [Configuration](#configuration)
- [Retry Policy](#retry-policy)
- [Development](#development)
- [Testing](#testing)

Expand Down Expand Up @@ -343,6 +344,26 @@ Nexa currently supports:
- Token refresh and retry handling
- Custom transports for stubbing and isolated tests

## Retry Policy

`NXRetryPolicy` retries `GET`, `HEAD`, `PUT`, `DELETE`, and `OPTIONS` by default when a configured retryable status code or transport error occurs. `POST` and `PATCH` remain single-attempt requests unless you explicitly add them to `retryableMethods` for an endpoint that safely accepts repeated requests.

For retryable `429` and `503` responses, Nexa accepts `Retry-After` delay seconds and HTTP-date values. A valid server value replaces local backoff, is capped by `maximumServerDelay` (60 seconds by default), and is recorded through `NXRetryLog`. `Jitter.full` changes only local backoff delays and never shortens a server-provided delay.

```swift
let retryPolicy = NXRetryPolicy(
maxAttempts: 3,
retryableMethods: [.get, .post],
maximumServerDelay: 30,
jitter: .none
)

let user = try await client
.post("/users")
.retry(retryPolicy)
.send(as: User.self)
```

## Development

Nexa keeps SwiftLint out of the distributable package graph so package consumers do not inherit maintainer lint rules.
Expand Down
2 changes: 1 addition & 1 deletion Sources/Nexa/Core/NXHTTPMethod.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import Foundation

/// HTTP methods supported by Nexa request builders.
public enum NXHTTPMethod: String, Sendable {
public enum NXHTTPMethod: String, Sendable, Hashable {
case get = "GET"
case post = "POST"
case put = "PUT"
Expand Down
35 changes: 34 additions & 1 deletion Sources/Nexa/Core/NXRetryPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@
import Foundation

/// Retry behavior applied to a request when execution fails.
///
/// ## Overview
///
/// By default, Nexa retries `GET`, `HEAD`, `PUT`, `DELETE`, and `OPTIONS` requests
/// for retryable transport errors and configured status codes. Add `POST` or `PATCH`
/// to ``retryableMethods`` only when the endpoint safely accepts repeated requests.
///
/// For `429` and `503` responses, a valid `Retry-After` value takes precedence over
/// local backoff and is limited by ``maximumServerDelay``. ``Jitter`` applies only to
/// local backoff delays.
public struct NXRetryPolicy: Sendable {
/// Delay strategy used between retry attempts.
public enum Backoff: Sendable {
Expand All @@ -28,26 +38,49 @@ public struct NXRetryPolicy: Sendable {
}
}

/// Randomization applied to local backoff delays.
public enum Jitter: Sendable, Equatable {
/// Keeps the local backoff delay unchanged.
case none
/// Uses a random value within the local backoff delay range.
case full
}

/// Maximum number of attempts including the initial request.
public var maxAttempts: Int
/// Delay strategy used between retry attempts.
public var backoff: Backoff
/// HTTP status codes that are eligible for retry.
public var retryableStatusCodes: Set<Int>
/// HTTP methods that are eligible for retry.
public var retryableMethods: Set<NXHTTPMethod>
/// Upper limit applied to a server-provided retry delay.
public var maximumServerDelay: TimeInterval
/// Randomization applied to local backoff delays.
public var jitter: Jitter

/// Creates a retry policy.
///
/// - Parameters:
/// - maxAttempts: Maximum number of attempts including the initial request.
/// - backoff: Delay strategy used between retry attempts.
/// - retryableStatusCodes: Status codes that should trigger a retry.
/// - retryableMethods: HTTP methods that should trigger a retry.
/// - maximumServerDelay: Upper limit applied to a server-provided retry delay.
/// - jitter: Randomization applied to local backoff delays.
public init(
maxAttempts: Int,
backoff: Backoff = .fixed(0),
retryableStatusCodes: Set<Int> = [408, 429, 500, 502, 503, 504]
retryableStatusCodes: Set<Int> = [408, 429, 500, 502, 503, 504],
retryableMethods: Set<NXHTTPMethod> = [.get, .head, .put, .delete, .options],
maximumServerDelay: TimeInterval = 60,
jitter: Jitter = .none
) {
self.maxAttempts = max(1, maxAttempts)
self.backoff = backoff
self.retryableStatusCodes = retryableStatusCodes
self.retryableMethods = retryableMethods
self.maximumServerDelay = max(0, maximumServerDelay)
self.jitter = jitter
}
}
14 changes: 14 additions & 0 deletions Sources/Nexa/Nexa.docc/Nexa.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,20 @@ let response = try await client
.send()
```

## Retry Policy

``NXRetryPolicy`` retries `GET`, `HEAD`, `PUT`, `DELETE`, and `OPTIONS` by default when a configured status code or a retryable transport error occurs. Add `POST` or `PATCH` to `retryableMethods` only when the server can safely receive the same request more than once.

For `429` and `503`, a valid `Retry-After` response header takes precedence over local backoff. Nexa accepts delay seconds and HTTP-date values, limits the result with `maximumServerDelay`, and records the selected delay through ``NXRetryLog``. Local ``NXRetryPolicy/Jitter`` does not change a server-provided delay.

```swift
let policy = NXRetryPolicy(
maxAttempts: 3,
retryableMethods: [.get, .post],
maximumServerDelay: 30
)
```

## Migration

Nexa 1.3 removes `NXRequestBuilder.raw()` and `NXTypedRequestBuilder.raw()`. Use `NXRequestBuilder.send()` for a raw response.
Expand Down
11 changes: 7 additions & 4 deletions Sources/Nexa/Runtime/NXRequestExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ enum NXRequestExecutor {
static func executeRaw(
clientConfiguration: NXClientConfiguration,
responseCacheStore: NXResponseCacheStore?,
requestSpec: RequestSpec
requestSpec: RequestSpec,
retryExecutionDependencies: NXRetryExecutionDependencies = .live
) async throws -> NXRawResponse {
do {
let request = try NXRequestAssembler.assemble(
Expand All @@ -31,7 +32,8 @@ enum NXRequestExecutor {
interceptors: runtimeInterceptors(
clientConfiguration: clientConfiguration,
responseCacheStore: responseCacheStore,
requestSpec: requestSpec
requestSpec: requestSpec,
retryExecutionDependencies: retryExecutionDependencies
),
transport: clientConfiguration.transport
)
Expand Down Expand Up @@ -74,10 +76,11 @@ enum NXRequestExecutor {
private static func runtimeInterceptors(
clientConfiguration: NXClientConfiguration,
responseCacheStore: NXResponseCacheStore?,
requestSpec: RequestSpec
requestSpec: RequestSpec,
retryExecutionDependencies: NXRetryExecutionDependencies
) -> [any NXHTTPInterceptor] {
var interceptors: [any NXHTTPInterceptor] = [
NXRetryInterceptor(),
NXRetryInterceptor(dependencies: retryExecutionDependencies),
NXAuthInterceptor()
]
interceptors.append(contentsOf: clientConfiguration.interceptors)
Expand Down
46 changes: 46 additions & 0 deletions Sources/Nexa/Runtime/NXRetryExecutionDependencies.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// NXRetryExecutionDependencies.swift
// Nexa
//
// Created by opfic on 8/22/26.
//

import Foundation

struct NXRetryExecutionDependencies: Sendable {
let now: @Sendable () -> Date
let sleep: @Sendable (TimeInterval) async throws -> Void
let randomUnit: @Sendable () -> Double

static let live = Self(
now: { Date() },
sleep: { delay in
let nanoseconds = Self.nanoseconds(for: delay)

guard 0 < nanoseconds else {
return
}

try await Task.sleep(nanoseconds: nanoseconds)
},
randomUnit: { Double.random(in: 0...1) }
)

static func nanoseconds(for delay: TimeInterval) -> UInt64 {
guard 0 < delay else {
return 0
}

guard delay.isFinite else {
return .max
}

let maximumSeconds = TimeInterval(UInt64.max / 1_000_000_000)

guard delay <= maximumSeconds else {
return .max
}

return UInt64(delay * 1_000_000_000)
}
}
Loading