diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e712a96..54320b7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,40 +14,48 @@ jobs:
# ── iOS ────────────────────────────────────────────────────────────────────
ios:
name: iOS ${{ matrix.ios }} / Xcode ${{ matrix.xcode }}
- runs-on: macos-15
+ runs-on: xcode-27
+ timeout-minutes: 30
strategy:
matrix:
include:
- - xcode: "16.3"
- ios: "18.3"
- simulator: "iPhone 16"
+ - xcode: "27.1"
+ ios: "27.0"
+ simulator: "iPhone 18 Pro"
steps:
- uses: actions/checkout@v4
- name: Select Xcode ${{ matrix.xcode }}
- run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app
+ run: |
+ sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app
+ xcodebuild -version
- name: Build & Test (iOS)
+ # pipefail is not set by default, so a failing test would be hidden by a pipe.
+ # xcpretty is dropped for the same reason: a missing tool fails the job.
run: |
+ set -o pipefail
xcodebuild test \
-scheme Nexio \
-destination "platform=iOS Simulator,name=${{ matrix.simulator }},OS=${{ matrix.ios }}" \
-enableCodeCoverage YES \
CODE_SIGN_IDENTITY="" \
- CODE_SIGNING_REQUIRED=NO \
- | xcpretty
+ CODE_SIGNING_REQUIRED=NO
# ── macOS ──────────────────────────────────────────────────────────────────
macos:
name: macOS / Swift Package
- runs-on: macos-15
+ runs-on: xcode-27
+ timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- - name: Select Xcode 16.3
- run: sudo xcode-select -s /Applications/Xcode_16.3.app
+ - name: Select Xcode 27.1
+ run: |
+ sudo xcode-select -s /Applications/Xcode_27.1.app
+ xcodebuild -version
- name: Build
run: swift build -c release
@@ -57,33 +65,52 @@ jobs:
- name: Generate coverage report
run: |
+ set -euo pipefail
+ test_binary=$(find .build -type f \
+ -path '*/NexioTests.xctest/Contents/MacOS/NexioTests' \
+ -print -quit)
+ profile=$(find .build -type f \
+ -path '*/codecov/default.profdata' \
+ -print -quit)
+ if [ -z "$test_binary" ]; then
+ echo "Could not find the NexioTests executable under .build." >&2
+ exit 1
+ fi
+ if [ -z "$profile" ]; then
+ echo "Could not find the Swift code coverage profile under .build." >&2
+ exit 1
+ fi
xcrun llvm-cov export \
- .build/debug/NexioPackageTests.xctest/Contents/MacOS/NexioPackageTests \
- -instr-profile .build/debug/codecov/default.profdata \
+ "$test_binary" \
+ -instr-profile "$profile" \
-ignore-filename-regex ".build|Tests" \
-format lcov > coverage.lcov
+ test -s coverage.lcov # fail if the export produced nothing
- name: Upload to Codecov
- uses: codecov/codecov-action@v4
+ uses: codecov/codecov-action@v5
with:
+ token: ${{ secrets.CODECOV_TOKEN }}
files: coverage.lcov
fail_ci_if_error: false
- env:
- CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
# ── Swift 6 strict concurrency ────────────────────────────────────────────
swift6:
name: Swift 6 strict concurrency check
- runs-on: macos-15
+ runs-on: xcode-27
+ timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- - name: Select Xcode 16.3
- run: sudo xcode-select -s /Applications/Xcode_16.3.app
+ - name: Select Xcode 27.1
+ run: |
+ sudo xcode-select -s /Applications/Xcode_27.1.app
+ xcodebuild -version
- - name: Build with strict concurrency
+ - name: Build library and tests with strict concurrency
+ # --build-tests compiles the test target too, so concurrency errors in tests fail CI.
run: |
- swift build \
+ swift build --build-tests \
-Xswiftc -strict-concurrency=complete \
-Xswiftc -warnings-as-errors
diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml
index 17e46b8..33e4c66 100644
--- a/.github/workflows/danger.yml
+++ b/.github/workflows/danger.yml
@@ -7,7 +7,7 @@ on:
jobs:
danger:
name: Danger Swift
- runs-on: macos-15
+ runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
permissions:
pull-requests: write
@@ -17,13 +17,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- - name: Select Xcode 16.3
- run: sudo xcode-select -s /Applications/Xcode_16.3.app
-
- - name: Install Danger Swift
- run: brew install danger/tap/danger-swift
-
- name: Run Danger
- run: danger-swift ci
+ uses: danger/swift@fcb64d7a1bbdb1f3f6ab28a5b48eda751a29f04b # Node 24 fix
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/pr-agent.yml b/.github/workflows/pr-agent.yml
new file mode 100644
index 0000000..8c55294
--- /dev/null
+++ b/.github/workflows/pr-agent.yml
@@ -0,0 +1,46 @@
+name: PR Agent
+
+on:
+ pull_request:
+ branches: [main]
+ types: [opened, reopened, ready_for_review, synchronize]
+
+permissions:
+ contents: read
+ issues: write
+ pull-requests: write
+
+concurrency:
+ group: pr-agent-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ review:
+ name: AI pull request review
+ runs-on: ubuntu-latest
+ if: github.event.sender.type != 'Bot'
+ steps:
+ - name: Check for the model API key
+ id: api-key
+ env:
+ OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
+ run: |
+ if [ -n "$OPENAI_KEY" ]; then
+ echo "configured=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "configured=false" >> "$GITHUB_OUTPUT"
+ echo "PR-Agent skipped: configure the OPENAI_KEY Actions secret to enable reviews."
+ fi
+
+ - name: Review pull request with PR-Agent
+ if: steps.api-key.outputs.configured == 'true'
+ uses: the-pr-agent/pr-agent@v0.47.0
+ env:
+ OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ github_action_config.auto_review: "true"
+ github_action_config.auto_describe: "false"
+ github_action_config.auto_improve: "false"
+ github_action_config.pr_actions: '["opened", "reopened", "ready_for_review"]'
+ github_action_config.handle_push_trigger: "true"
+ github_action_config.push_commands: '["/review"]'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 887fb68..97d3515 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,27 @@ Nexio uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+No public signatures changed. The entries below are behavior fixes and internal changes.
+
+### Fixed
+- HTTP 5xx and 429 responses are now retried when a retry policy is configured. Previously they were never retried.
+- Per-endpoint `auth` is no longer overwritten by a registered `AuthInterceptor`. `.some(.none)` sends no token.
+- Calling a refresh endpoint from inside an `AuthInterceptor` provider no longer re-enters the same provider.
+- POST and PATCH are no longer retried by default. Only idempotent methods retry.
+- Cancelling a request stops retries and throws `CancellationError`, instead of retrying or reporting a failure.
+- `NexioImage` no longer shows the failure image when a load is cancelled because the view moved on.
+- `RetryPolicy` backoff is capped at 60 seconds, which prevents an integer overflow trap on large exponential delays.
+- `configure(_:)` can now disable retry. Previously the retry setting stuck once enabled.
+- `configure(_:)` invalidates the previous `URLSession` instead of leaking it.
+- Retry is capped at 10 retries per request, so a custom interceptor can't retry indefinitely.
+
+### Changed
+- `NexioConfig.retry` is now held by the client and applied inside the request pipeline. It no longer inserts a `RetryInterceptor` into the interceptor list.
+- JSON decoding happens off the `NexioClient` actor.
+- Image decoding in `ImageLoader` happens off the actor.
+- `ImageLoader.prefetch(_:)` fetches at most 4 images at a time.
+- `URLError.cancelled` surfaces as `CancellationError`, not `NexioError.unknown`.
+
## [1.0.0] - 2026-06-07
### Added
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a92a005..df253df 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -45,6 +45,12 @@ docs/short-description
- [ ] `CHANGELOG.md` updated under `[Unreleased]`
- [ ] PR description explains *what* changed and *why*
+## Automated PR reviews
+
+This repository can run [PR-Agent](https://github.com/The-PR-Agent/pr-agent) on pull requests targeting `main`. To enable reviews, add an `OPENAI_KEY` repository secret under **Settings → Secrets and variables → Actions**. PR-Agent reviews newly opened and updated pull requests and posts its feedback to the PR. The workflow skips reviews when the secret is not configured and cannot access repository secrets for pull requests from forks.
+
+PR-Agent sends pull request content to the configured model provider. Model API usage may incur charges; review the provider's data and pricing terms before enabling it. The workflow requests only read access to repository contents and write access to pull requests and issues so it can post review feedback.
+
## Reporting Issues
Use GitHub Issues. For security vulnerabilities, see [SECURITY.md](SECURITY.md).
diff --git a/Dangerfile.swift b/Dangerfile.swift
index 2123a0a..dc05e10 100644
--- a/Dangerfile.swift
+++ b/Dangerfile.swift
@@ -13,18 +13,20 @@ if additions + deletions > bigPRThreshold {
warn("PR is large (\(additions + deletions) lines). Consider splitting.")
}
-// Require CHANGELOG update for non-trivial PRs
+// Require CHANGELOG update for non-trivial PRs.
+// Trivial means the title starts with a listed prefix, not that it contains one.
+let title = danger.github.pullRequest.title
+let trivialPrefixes = ["[trivial]", "docs:", "chore:"]
+let isTrivial = trivialPrefixes.contains { title.hasPrefix($0) }
+
let hasChangelogUpdate = danger.git.modifiedFiles.contains("CHANGELOG.md")
|| danger.git.createdFiles.contains("CHANGELOG.md")
-let isTrivial = danger.github.pullRequest.title.contains("[trivial]")
- || danger.github.pullRequest.title.contains("docs:")
- || danger.github.pullRequest.title.contains("chore:")
if !hasChangelogUpdate && !isTrivial {
warn("No `CHANGELOG.md` update. Add an entry under `[Unreleased]` or prefix title with `chore:`/`docs:` to skip.")
}
// Require PR description
-let bodyLength = danger.github.pullRequest.body?.count ?? 0
+let bodyLength = danger.github.pullRequest.body?.trimmingCharacters(in: .whitespacesAndNewlines).count ?? 0
if bodyLength < 20 {
fail("PR description is too short. Explain what changed and why.")
}
diff --git a/Example/NexioExample.swiftpm/AppModule/ContentView.swift b/Example/NexioExample.swiftpm/AppModule/ContentView.swift
index fdcf698..ce2640e 100644
--- a/Example/NexioExample.swiftpm/AppModule/ContentView.swift
+++ b/Example/NexioExample.swiftpm/AppModule/ContentView.swift
@@ -43,8 +43,8 @@ struct ContentView: View {
config.logLevel = .all
await NexioClient.shared.configure(config)
- // NOTE: setting `config.retry` alone does not enable retries yet —
- // register the interceptor explicitly to see retry behaviour in action.
+ // Register the interceptor explicitly here to demonstrate custom retry
+ // callbacks. The built-in config.retry policy also works independently.
await NexioClient.shared.addInterceptor(RetryInterceptor(policy: .standard))
await NexioClient.shared.addInterceptor(ConsoleLogInterceptor())
await NexioClient.shared.addInterceptor(HeaderCaptureInterceptor())
diff --git a/Example/README.md b/Example/README.md
index a79d11b..f48f4fd 100644
--- a/Example/README.md
+++ b/Example/README.md
@@ -46,7 +46,7 @@ AppModule/
Two SDK gaps were worked around here rather than papered over silently:
-- **`config.retry` is inert** (`NexioConfig.retry` is stored but never wired to a `RetryInterceptor`) — `ContentView.configureNexio()` registers `RetryInterceptor(policy: .standard)` explicitly, with a `// NOTE:` comment explaining why.
+- **`config.retry`** is applied by the built-in request pipeline. `ContentView.configureNexio()` registers `RetryInterceptor(policy: .standard)` explicitly to demonstrate custom retry callbacks.
- **`Endpoint.auth: AuthStrategy?` `.none` ambiguity** (`.none` resolves to `Optional.none`/"inherit", not `AuthStrategy.none`/"skip") — `EndpointAuthOverrideEndpoint` uses `.apiKey(...)` instead, with a doc comment spelling out the footgun and the `.some(.none)` workaround.
`Support/Interceptors.swift` also shows the Swift 6–correct way to hand data from a `Sendable` `Interceptor` back to `@MainActor` view models (actor-backed stores) — the SDK's own `AuthTests.CapturingInterceptor` gets this wrong and fails to compile under strict concurrency.
diff --git a/README.md b/README.md
index 68a3b93..938910b 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,84 @@ Typed JSON requests · Auth strategies · Retry with backoff · Interceptor pipe
---
+## Release versioning
+
+The next release for the changes in this branch should be tagged **2.0.0**. The public Swift signatures remain source-compatible, but request behavior changes in ways applications can observe: configured retries now include HTTP 429/5xx responses and can be disabled on reconfiguration, built-in retry policies limit retries to idempotent methods, endpoint auth overrides take precedence, and cancellation uses `CancellationError`. These changes can affect request counts, credentials, and error handling, so a major release is the safer compatibility signal than 1.1.0.
+
+---
+
+## Architecture
+
+Nexio is split into a few layers. `NexioClient` is the only actor that owns request state, and it never blocks on I/O or decoding.
+
+### Module map
+
+```mermaid
+flowchart LR
+ Core["Core
NexioClient, NexioConfig, Endpoint"] --> Interceptor["Interceptor
Interceptor, RetryInterceptor"]
+ Core --> Auth["Auth
AuthStrategy, AuthInterceptor"]
+ Core --> Errors["Errors
NexioError"]
+ Core --> Internal["Internal
URLSession and HTTP helpers"]
+ Image["Image
ImageLoader, NexioImage"] --> Internal
+ Auth --> Interceptor
+```
+
+### Request pipeline
+
+Every request goes through the same loop. Retries re-enter it from the top, so each attempt gets fresh adaptation, including a fresh token.
+
+```mermaid
+flowchart TD
+ App["App code
get, post, request(endpoint)"] --> Build["Build URLRequest
baseURL, headers, auth"]
+ Build --> Adapt["Adapt
interceptors in order
AuthInterceptor skipped when endpoint sets auth"]
+ Adapt --> Send["URLSession.data(for:)"]
+ Send --> Status{"Transport error
or 429 / 5xx?"}
+ Status -- "no, 2xx or 4xx" --> Decode["Decode JSON
off the actor"]
+ Status -- "yes" --> Retry{"Built-in retry allowed?
idempotent, retryable,
under attempt limit"}
+ Retry -- "yes, after backoff" --> Adapt
+ Retry -- "no" --> Throw["Throw NexioError
or CancellationError"]
+ Decode --> Done["Return value"]
+ Throw --> App
+ Done --> App
+```
+
+### Token refresh without recursion
+
+`AuthInterceptor` calls your provider before each request. If the provider itself calls Nexio to refresh the token, that refresh request skips the dynamic auth interceptor, so it can't loop back into the same provider.
+
+```mermaid
+sequenceDiagram
+ participant App
+ participant Client as NexioClient
+ participant Provider as AuthInterceptor provider
+ participant Net as URLSession
+ App->>Client: get("/orders")
+ Client->>Provider: adapt
+ Provider->>Client: post("/auth/refresh")
+ Note right of Client: AuthInterceptor skipped for this call
+ Client->>Net: refresh request
+ Net-->>Client: new token
+ Client-->>Provider: new token
+ Provider-->>Client: Bearer new token
+ Client->>Net: GET /orders
+ Net-->>Client: 200 JSON
+ Client-->>App: decoded orders
+```
+
+### Rules the design guarantees
+
+| Rule | Why |
+|------|-----|
+| Actor holds only state and bookkeeping | I/O and JSON decoding run outside it, so one large response never queues other requests. |
+| Built-in retry policies retry only idempotent methods (GET, HEAD, PUT, DELETE, OPTIONS) | POST and PATCH could duplicate work on the server. Custom interceptors can define other behavior. |
+| Built-in retry covers network errors, timeouts, 429, and 5xx | These are transient. Other 4xx responses are final. |
+| Hard limit of 10 retries per request | A custom interceptor that always returns `true` can't loop forever. |
+| Endpoint `auth` overrides the dynamic token | A public endpoint sends no token, and a per-endpoint token is never overwritten. |
+| Cancellation stops retries and throws `CancellationError` | Cancelled work stops quickly and doesn't show up as a failure. |
+| Reconfiguring replaces the old session and retry policy | No leaked `URLSession`, and retry can be turned off. |
+
+---
+
## Installation
### Swift Package Manager
@@ -62,7 +140,7 @@ Or add it in Xcode: **File → Add Package Dependencies**, paste the repo URL.
var config = NexioConfig()
config.baseURL = URL(string: "https://api.example.com")
config.timeout = 15
-config.retry = .standard // 3 attempts, exponential backoff
+config.retry = .standard // 3 retries (up to 4 requests), exponential backoff
config.logLevel = .errors
await NexioClient.shared.configure(config)
await NexioClient.shared.setAuth(.bearer("your-token"))
@@ -122,10 +200,12 @@ struct PublicEndpoint: Endpoint {
var baseURL: URL { URL(string: "https://api.example.com")! }
var path: String { "/status" }
var method: HTTPMethod { .get }
- var auth: AuthStrategy? { .some(.none) } // skip global auth for this request
+ var auth: AuthStrategy? { .some(.none) } // no token, and no AuthInterceptor
}
```
+`auth` has three cases. `nil` inherits the global auth and runs any `AuthInterceptor`. `.some(.none)` removes common credential headers and disables cookie handling. Do not add custom credentials through `headers`. `.some(strategy)` uses that strategy and skips `AuthInterceptor`, so the dynamic token never overwrites it.
+
---
## Type-Safe Endpoints
@@ -157,19 +237,23 @@ let results: [User] = try await NexioClient.shared.request(SearchUsers(query
## Retry
```swift
-// Via config (recommended — applied automatically)
+// Via config (recommended): applies to every request
config.retry = .standard // 3 retries, exponential backoff
config.retry = RetryPolicy(maxAttempts: 5,
backoff: .linear(seconds: 2)) // custom
-// Via interceptor (for per-client or programmatic control)
+// Via interceptor: for composing with your own interceptors
await NexioClient.shared.addInterceptor(
RetryInterceptor(policy: .standard)
)
```
-**Retried automatically on:** `.noInternet`, `.timeout`, and 5xx `serverError`.
-**Not retried:** 4xx errors (client errors are not transient).
+`maxAttempts` is the number of retries, in addition to the initial request. The standard policy can therefore send up to 4 requests total. If both config retry and retry interceptors are registered, the config policy gets first chance and the interceptor callbacks are consulted only when it declines; both share the same attempt counter.
+
+**Retried on:** `.noInternet`, `.timeout`, HTTP 429, and HTTP 5xx.
+**Built-in retry policies retry only idempotent methods:** GET, HEAD, PUT, DELETE, OPTIONS. A custom `Interceptor.retry` can define different behavior.
+**Never retried by default:** POST and PATCH, and any 4xx other than 429.
+**Limits:** backoff is capped at 60 seconds, and a request is retried at most 10 times.
---
@@ -260,7 +344,17 @@ async let comments: [Comment] = NexioClient.shared.get("/comments")
let (u, p, c) = try await (users, posts, comments)
```
-Actor isolation serializes only the microsecond-scale bookkeeping (building requests, applying headers, decoding JSON). Network round-trips never block other callers.
+Actor isolation covers only the short bookkeeping steps: building requests and applying headers. Network round-trips and JSON decoding run outside the actor, so a large response never blocks other callers.
+
+Cancelling the task that awaits a request stops retries and the network call, and the await throws `CancellationError`:
+
+```swift
+let task = Task {
+ let posts: [Post] = try await NexioClient.shared.get("/feed")
+ return posts
+}
+task.cancel() // stops retries and the network call
+```
---
diff --git a/Sources/Nexio/Auth/AuthInterceptor.swift b/Sources/Nexio/Auth/AuthInterceptor.swift
index 4a3ce3b..defa3d6 100644
--- a/Sources/Nexio/Auth/AuthInterceptor.swift
+++ b/Sources/Nexio/Auth/AuthInterceptor.swift
@@ -8,6 +8,10 @@ import Foundation
/// Use this when your token can change over time (e.g. OAuth refresh).
/// For static tokens, prefer ``NexioClient/setAuth(_:)`` instead.
///
+/// The provider runs before each request that doesn't set its own
+/// ``Endpoint/auth``. A request sent from inside the provider, such as a token
+/// refresh call, skips this interceptor, so it can't recurse into itself.
+///
/// ```swift
/// let authInterceptor = AuthInterceptor {
/// await TokenStore.shared.currentToken()
@@ -29,7 +33,9 @@ public struct AuthInterceptor: Interceptor {
public func adapt(_ request: URLRequest, for session: URLSession) async throws -> URLRequest {
var mutableRequest = request
- let strategy = await provider()
+ let strategy = await AuthResolution.$isResolving.withValue(true) {
+ await provider()
+ }
switch strategy {
case .bearer(let token):
mutableRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
@@ -50,4 +56,14 @@ public struct AuthInterceptor: Interceptor {
) async -> Bool {
false // Auth interceptor does not handle retries
}
-}
\ No newline at end of file
+}
+
+// MARK: - AuthResolution
+
+/// Marks the span during which an ``AuthInterceptor`` provider is running.
+///
+/// Requests made inside that span are not given the dynamic token again.
+/// This is what stops a refresh call from recursing into the same provider.
+enum AuthResolution {
+ @TaskLocal static var isResolving: Bool = false
+}
diff --git a/Sources/Nexio/Core/Endpoint.swift b/Sources/Nexio/Core/Endpoint.swift
index 010ca6b..d76286e 100644
--- a/Sources/Nexio/Core/Endpoint.swift
+++ b/Sources/Nexio/Core/Endpoint.swift
@@ -48,9 +48,13 @@ public protocol Endpoint: Sendable {
/// Per-request auth override.
///
- /// - `nil` — inherit the global ``AuthStrategy`` set on ``NexioClient``.
- /// - `.none` — skip auth entirely for this request.
- /// - Any other value — use it instead of the global strategy.
+ /// - `nil`: inherit the global ``AuthStrategy`` set on ``NexioClient``, and
+ /// apply any registered ``AuthInterceptor``.
+ /// - `.some(.none)`: skip Nexio-managed auth for this request, remove common
+ /// credential headers, and disable cookie handling. No ``AuthInterceptor``
+ /// runs. Custom credential headers should not be added through `headers`.
+ /// - `.some(strategy)`: use this strategy instead. No ``AuthInterceptor``
+ /// runs, so the dynamic token can't overwrite it.
var auth: AuthStrategy? { get }
}
@@ -61,4 +65,4 @@ public extension Endpoint {
var queryItems: [URLQueryItem] { [] }
var body: (any Encodable)? { nil }
var auth: AuthStrategy? { nil }
-}
\ No newline at end of file
+}
diff --git a/Sources/Nexio/Core/NexioClient.swift b/Sources/Nexio/Core/NexioClient.swift
index f567a68..f66cb6f 100644
--- a/Sources/Nexio/Core/NexioClient.swift
+++ b/Sources/Nexio/Core/NexioClient.swift
@@ -7,8 +7,9 @@ import Foundation
/// The main entry point for Nexio networking.
///
-/// `NexioClient` is a Swift actor — all configuration mutations and requests
-/// are automatically serialised, giving you zero data-race risk.
+/// `NexioClient` is a Swift actor. Configuration changes and request bookkeeping
+/// are serialised by the actor; network I/O and JSON decoding run outside it,
+/// so a large response never blocks other requests.
///
/// **Quick start**
/// ```swift
@@ -26,13 +27,19 @@ public actor NexioClient {
/// Shared instance. Configure it once at app launch, then use anywhere.
public static let shared = NexioClient()
+ // MARK: - Constants
+
+ /// Hard ceiling on attempts per request, independent of any interceptor.
+ /// Stops a custom interceptor that always returns `true` from retrying forever.
+ private static let attemptCeiling = 10
+
// MARK: - Private State
private var config: NexioConfig = NexioConfig()
private var authStrategy: AuthStrategy = .none
+ private var retryPolicy: RetryPolicy = .none
private var interceptors: [any Interceptor] = []
private var session: URLSession = URLSession.shared
- private let decoder: JSONDecoder = JSONDecoder()
private let encoder: JSONEncoder = JSONEncoder()
// MARK: - Init
@@ -44,22 +51,31 @@ public actor NexioClient {
/// Applies a new configuration. Rebuilds the underlying `URLSession`.
///
+ /// Requests already in flight finish on the previous session, which is then
+ /// invalidated. Retry settings are applied from this point on.
+ ///
/// - Parameter config: The configuration to apply.
public func configure(_ config: NexioConfig) {
self.config = config
+ let previous = session
+
let sessionConfig = URLSessionConfiguration.default
sessionConfig.timeoutIntervalForRequest = config.timeout
- sessionConfig.httpAdditionalHeaders = config.defaultHeaders as [AnyHashable: Any]
+ // Default headers are applied per request (see applyDefaultHeaders), not
+ // at session level. Session-level headers would bypass no-auth overrides.
if let protocolClasses = config.protocolClasses {
sessionConfig.protocolClasses = protocolClasses + (sessionConfig.protocolClasses ?? [])
}
session = URLSession(configuration: sessionConfig)
- // Wire the config's retry policy as the first interceptor so callers
- // don't have to add RetryInterceptor manually when using NexioConfig.retry.
- if config.retry.maxAttempts > 0 {
- interceptors.removeAll { $0 is RetryInterceptor }
- interceptors.insert(RetryInterceptor(policy: config.retry), at: 0)
+
+ // Release the session we replaced. Tasks already running are allowed to finish.
+ if previous !== URLSession.shared {
+ previous.finishTasksAndInvalidate()
}
+
+ // Retry is client state rather than an interceptor, so reconfiguring can
+ // always change or disable it.
+ retryPolicy = config.retry
}
/// Sets the global authentication strategy.
@@ -96,11 +112,14 @@ public actor NexioClient {
headers: [String: String] = [:]
) async throws -> T {
let request = try buildRequest(url: url, method: .get, headers: headers, body: nil as String?)
- return try await perform(request)
+ return try await perform(request, skipDynamicAuth: false)
}
/// Performs a POST request, encoding `body` as JSON, and decodes the response.
///
+ /// POST is not retried by default, because repeating it can duplicate work
+ /// on the server.
+ ///
/// - Parameters:
/// - url: Absolute URL string, or a path appended to ``NexioConfig/baseURL``.
/// - body: JSON-encodable request body.
@@ -113,7 +132,7 @@ public actor NexioClient {
headers: [String: String] = [:]
) async throws -> T {
let request = try buildRequest(url: url, method: .post, headers: headers, body: body)
- return try await perform(request)
+ return try await perform(request, skipDynamicAuth: false)
}
/// Performs a PUT request, encoding `body` as JSON, and decodes the response.
@@ -130,11 +149,13 @@ public actor NexioClient {
headers: [String: String] = [:]
) async throws -> T {
let request = try buildRequest(url: url, method: .put, headers: headers, body: body)
- return try await perform(request)
+ return try await perform(request, skipDynamicAuth: false)
}
/// Performs a PATCH request, encoding `body` as JSON, and decodes the response.
///
+ /// PATCH is not retried by default, for the same reason as POST.
+ ///
/// - Parameters:
/// - url: Absolute URL string, or a path appended to ``NexioConfig/baseURL``.
/// - body: JSON-encodable request body.
@@ -147,7 +168,7 @@ public actor NexioClient {
headers: [String: String] = [:]
) async throws -> T {
let request = try buildRequest(url: url, method: .patch, headers: headers, body: body)
- return try await perform(request)
+ return try await perform(request, skipDynamicAuth: false)
}
/// Performs a DELETE request. Does not decode a response body.
@@ -161,7 +182,7 @@ public actor NexioClient {
headers: [String: String] = [:]
) async throws {
let request = try buildRequest(url: url, method: .delete, headers: headers, body: nil as String?)
- let (data, http) = try await executeWithInterceptors(request, attempt: 0)
+ let (data, http) = try await send(request, skipDynamicAuth: false)
logIfNeeded(request: request, response: http, data: data)
if let error = http.nexioError(data: data) {
throw error
@@ -173,6 +194,9 @@ public actor NexioClient {
/// Performs a request described by a typed ``Endpoint`` conformer and decodes
/// the JSON response.
///
+ /// If the endpoint sets ``Endpoint/auth``, that strategy is used for this
+ /// request and any registered ``AuthInterceptor`` is skipped.
+ ///
/// ```swift
/// let user: User = try await NexioClient.shared.request(GetUserEndpoint(id: 42))
/// ```
@@ -182,12 +206,12 @@ public actor NexioClient {
/// - Throws: ``NexioError``
public func request(_ endpoint: some Endpoint) async throws -> T {
let urlRequest = try buildRequest(from: endpoint)
- return try await perform(urlRequest)
+ return try await perform(urlRequest, skipDynamicAuth: endpoint.auth != nil)
}
// MARK: - Raw Data
- /// Fetches raw `Data` from a URL — used internally by ``ImageLoader``.
+ /// Fetches raw `Data` from a URL. Used internally by ``ImageLoader``.
///
/// - Parameter url: Absolute URL string.
/// - Returns: Raw response bytes.
@@ -200,77 +224,129 @@ public actor NexioClient {
request.httpMethod = HTTPMethod.get.rawValue
applyDefaultHeaders(to: &request)
applyAuth(authStrategy, to: &request)
- let (data, http) = try await executeWithInterceptors(request, attempt: 0)
+ let (data, http) = try await send(request, skipDynamicAuth: false)
+ logIfNeeded(request: request, response: http, data: data)
if let error = http.nexioError(data: data) { throw error }
return data
}
// MARK: - Private Helpers
- /// Runs a built request through the interceptor/retry pipeline, maps
- /// failure status codes to ``NexioError``, and decodes a successful
- /// response body as `T`.
+ /// Sends a built request and decodes a successful response body as `T`.
///
- /// Shared tail end for every JSON-returning call (`get`, `post`, `put`,
- /// `patch`, ``request(_:)``) — keeps status mapping, logging, and
- /// decode-error wrapping in one place.
+ /// Decoding runs off the actor, so a large payload doesn't hold up other callers.
///
- /// - Parameter request: The fully-built request to execute.
+ /// - Parameters:
+ /// - request: The fully built request.
+ /// - skipDynamicAuth: `true` when the request carries its own auth.
/// - Returns: The decoded response body.
- /// - Throws: ``NexioError/decodingFailed(underlying:data:)`` if `T`
- /// doesn't match the response shape, or any status-mapped ``NexioError``.
- private func perform(_ request: URLRequest) async throws -> T {
- let (data, http) = try await executeWithInterceptors(request, attempt: 0)
+ /// - Throws: ``NexioError/decodingFailed(underlying:data:)`` if `T` doesn't
+ /// match the response, or any status-mapped ``NexioError``.
+ private func perform(
+ _ request: URLRequest,
+ skipDynamicAuth: Bool
+ ) async throws -> T {
+ let (data, http) = try await send(request, skipDynamicAuth: skipDynamicAuth)
logIfNeeded(request: request, response: http, data: data)
if let error = http.nexioError(data: data) { throw error }
- do {
- return try decoder.decode(T.self, from: data)
- } catch {
- throw NexioError.decodingFailed(underlying: error, data: data)
- }
+ return try await NexioDecoding.decode(T.self, from: data)
}
- /// Adapts, sends, and — on failure — retries a request, consulting every
- /// registered ``Interceptor`` along the way.
+ /// Runs a request through the adapt → send → retry pipeline.
///
- /// Adapters run in insertion order before the request is sent; on
- /// failure, retry handlers run in *reverse* insertion order so the most
- /// specific interceptor gets first say. The first handler that returns
- /// `true` wins and the whole pipeline restarts.
+ /// Each attempt re-runs the full adapter chain, so a token-refreshing
+ /// ``AuthInterceptor`` mints a fresh credential per attempt.
///
- /// - Note: A retry restarts from the **original** `request`, not the
- /// already-`adapted` one — each attempt re-runs the full adapter chain
- /// from scratch, so e.g. a token-refreshing ``AuthInterceptor`` mints a
- /// fresh credential per attempt instead of resending a stale one.
+ /// Status failures (5xx, 429) and transport failures are both retry
+ /// candidates. When the pipeline stops retrying, the last response is
+ /// returned so the caller can map it to a ``NexioError``.
///
/// - Parameters:
- /// - request: The request to send (pre-adaptation).
- /// - attempt: Zero-based retry counter, threaded through to
- /// ``Interceptor/retry(_:dueTo:attempt:)``.
+ /// - original: The request before adaptation.
+ /// - skipDynamicAuth: Skip ``AuthInterceptor`` for this request.
/// - Returns: Raw response data and the parsed `HTTPURLResponse`.
- /// - Throws: The ``NexioError`` from the final attempt, if no
- /// interceptor opts to retry.
- private func executeWithInterceptors(
- _ request: URLRequest,
- attempt: Int
+ /// - Throws: `CancellationError` if the task was cancelled, otherwise the
+ /// transport ``NexioError`` from the final attempt.
+ private func send(
+ _ original: URLRequest,
+ skipDynamicAuth: Bool
) async throws -> (Data, HTTPURLResponse) {
+ // A token refresh issued from inside an AuthInterceptor provider must not
+ // go through that same interceptor again, or it would recurse.
+ let skipAuth = skipDynamicAuth || AuthResolution.isResolving
+ var attempt = 0
+
+ while true {
+ try Task.checkCancellation()
+ let adapted = try await adapt(original, skipDynamicAuth: skipAuth)
+
+ do {
+ let (data, http) = try await session.nexioData(for: adapted)
+ if let statusError = http.nexioError(data: data) {
+ if await shouldRetry(adapted, dueTo: statusError, attempt: attempt) {
+ attempt += 1
+ continue
+ }
+ // A retry delay or interceptor may have observed cancellation
+ // and declined the retry. Preserve cancellation instead of
+ // returning the last HTTP error response.
+ try Task.checkCancellation()
+ }
+ return (data, http)
+ } catch let error as NexioError {
+ if await shouldRetry(adapted, dueTo: error, attempt: attempt) {
+ attempt += 1
+ continue
+ }
+ if Task.isCancelled { throw CancellationError() }
+ throw error
+ }
+ }
+ }
+
+ /// Runs every interceptor's `adapt` in insertion order.
+ ///
+ /// ``AuthInterceptor`` is skipped when the request carries its own auth.
+ ///
+ /// - Parameters:
+ /// - request: The request to adapt.
+ /// - skipDynamicAuth: Whether to skip ``AuthInterceptor``.
+ /// - Returns: The adapted request.
+ private func adapt(_ request: URLRequest, skipDynamicAuth: Bool) async throws -> URLRequest {
var adapted = request
for interceptor in interceptors {
+ if skipDynamicAuth, interceptor is AuthInterceptor { continue }
adapted = try await interceptor.adapt(adapted, for: session)
}
+ return adapted
+ }
- do {
- let result = try await session.nexioData(for: adapted)
- return result
- } catch let nexioError as NexioError {
- // Walk interceptors in reverse for retry decisions
- for interceptor in interceptors.reversed() {
- if await interceptor.retry(adapted, dueTo: nexioError, attempt: attempt) {
- return try await executeWithInterceptors(request, attempt: attempt + 1)
- }
+ /// Decides whether a failed attempt should be retried.
+ ///
+ /// The client's configured ``NexioConfig/retry`` policy is consulted first,
+ /// then registered interceptors in reverse order. The first to return `true`
+ /// wins. Retries stop at `attemptCeiling` no matter what.
+ ///
+ /// - Parameters:
+ /// - request: The adapted request that failed.
+ /// - error: The failure to evaluate.
+ /// - attempt: Zero-based attempt counter.
+ /// - Returns: `true` to run another attempt.
+ private func shouldRetry(
+ _ request: URLRequest,
+ dueTo error: NexioError,
+ attempt: Int
+ ) async -> Bool {
+ guard !Task.isCancelled, attempt < Self.attemptCeiling else { return false }
+ if await retryPolicy.shouldRetry(request, dueTo: error, attempt: attempt) {
+ return true
+ }
+ for interceptor in interceptors.reversed() {
+ if await interceptor.retry(request, dueTo: error, attempt: attempt) {
+ return true
}
- throw nexioError
}
+ return false
}
/// Builds a `URLRequest` for a string-URL call: resolves `url` against
@@ -309,8 +385,8 @@ public actor NexioClient {
/// auth, and JSON-encodes the endpoint's body if present.
///
/// - Note: ``Endpoint/auth`` wins over the client's global auth strategy
- /// whenever it's non-`nil` — see ``Endpoint/auth`` for the
- /// `AuthStrategy?` semantics (and the `.none` ambiguity to watch for).
+ /// whenever it's non-`nil`. See ``Endpoint/auth`` for the `AuthStrategy?`
+ /// semantics, including the `.some(.none)` vs `nil` distinction.
///
/// - Parameter endpoint: The endpoint describing the request.
/// - Returns: A populated `URLRequest`, ready for the interceptor pipeline.
@@ -332,8 +408,15 @@ public actor NexioClient {
endpoint.headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
// Per-request auth takes precedence over global auth
- let effectiveAuth = endpoint.auth ?? authStrategy
- applyAuth(effectiveAuth, to: &request)
+ if case .some(.none) = endpoint.auth {
+ // Explicit no-auth: remove credentials that defaults or endpoint headers
+ // may have added, and prevent URLSession from adding stored cookies.
+ stripCredentialHeaders(from: &request)
+ request.httpShouldHandleCookies = false
+ } else {
+ let effectiveAuth = endpoint.auth ?? authStrategy
+ applyAuth(effectiveAuth, to: &request)
+ }
if let body = endpoint.body {
request.httpBody = try encoder.encode(body)
@@ -344,10 +427,9 @@ public actor NexioClient {
/// Resolves a user-supplied string to an absolute `URL`.
///
/// Strings that already parse with a scheme (e.g. `"https://..."`) pass
- /// through unchanged; anything else is treated as a path and appended to
- /// ``NexioConfig/baseURL``. Returns `nil` — surfaced by callers as
- /// ``NexioError/invalidURL(_:)`` — when neither applies, e.g. a malformed
- /// string with no `baseURL` configured to fall back on.
+ /// through unchanged. Anything else is treated as a path and appended to
+ /// ``NexioConfig/baseURL``. Returns `nil`, surfaced by callers as
+ /// ``NexioError/invalidURL(_:)``, when neither applies.
///
/// - Parameter urlString: Absolute URL string, or a relative path.
/// - Returns: The resolved `URL`, or `nil` if it can't be resolved.
@@ -355,7 +437,21 @@ public actor NexioClient {
if let absolute = URL(string: urlString), absolute.scheme != nil {
return absolute
}
- return config.baseURL.map { $0.appendingPathComponent(urlString) }
+ guard let baseURL = config.baseURL,
+ let relativeComponents = URLComponents(string: urlString) else {
+ return nil
+ }
+
+ // Treat a relative URL's query and fragment as URL components, not as
+ // part of the path passed to `appendingPathComponent` (`?` is escaped
+ // there, turning `/posts?_limit=10` into `/posts%3F_limit=10`).
+ let pathURL = baseURL.appendingPathComponent(relativeComponents.path)
+ guard var resolvedComponents = URLComponents(url: pathURL, resolvingAgainstBaseURL: false) else {
+ return nil
+ }
+ resolvedComponents.queryItems = relativeComponents.queryItems
+ resolvedComponents.fragment = relativeComponents.fragment
+ return resolvedComponents.url
}
/// Merges ``NexioConfig/defaultHeaders`` onto `request`, overwriting any
@@ -366,11 +462,25 @@ public actor NexioClient {
config.defaultHeaders.forEach { request.setValue($1, forHTTPHeaderField: $0) }
}
+ /// Header names treated as credentials. Removed for explicit no-auth requests.
+ private static let credentialHeaderNames = [
+ "Authorization", "Proxy-Authorization", "X-Api-Key", "Cookie"
+ ]
+
+ /// Removes credential headers from `request`, whatever added them.
+ ///
+ /// - Parameter request: The request to clean, in place.
+ private func stripCredentialHeaders(from request: inout URLRequest) {
+ for name in Self.credentialHeaderNames {
+ request.setValue(nil, forHTTPHeaderField: name)
+ }
+ }
+
/// Applies an ``AuthStrategy`` to `request` by setting the appropriate
- /// header(s); `.none` is a deliberate no-op.
+ /// header(s). `.none` is a deliberate no-op.
///
/// - Parameters:
- /// - strategy: The strategy to apply — global or per-endpoint.
+ /// - strategy: The strategy to apply, global or per-endpoint.
/// - request: The request to mutate in place.
private func applyAuth(_ strategy: AuthStrategy, to request: inout URLRequest) {
switch strategy {
@@ -386,7 +496,7 @@ public actor NexioClient {
}
/// Prints a request/response trace to the console per ``NexioConfig/logLevel``:
- /// silent for `.none`, failures only for `.errors`, full request + response
+ /// silent for `.none`, failures only for `.errors`, full request and response
/// lines for `.all`.
///
/// - Parameters:
@@ -399,9 +509,9 @@ public actor NexioClient {
break
case .errors:
guard !response.isSuccess else { return }
- print("[Nexio] ❌ \(request.httpMethod ?? "?") \(request.url?.absoluteString ?? "") → \(response.statusCode)")
+ print("[Nexio] ❌ \(request.httpMethod ?? "?") \(URLRedactor.redacted(request.url)) → \(response.statusCode)")
case .all:
- print("[Nexio] ➡️ \(request.httpMethod ?? "?") \(request.url?.absoluteString ?? "")")
+ print("[Nexio] ➡️ \(request.httpMethod ?? "?") \(URLRedactor.redacted(request.url))")
print("[Nexio] ⬅️ \(response.statusCode) (\(data.count) bytes)")
}
}
@@ -428,4 +538,47 @@ public func nexioPost(
body: some Encodable & Sendable
) async throws -> T {
try await NexioClient.shared.post(url, body: body)
-}
\ No newline at end of file
+}
+
+// MARK: - Decoding
+
+/// Decodes JSON on a detached task, so the client actor never does the work.
+enum NexioDecoding {
+ /// Decodes `data` as `T`. This is a nonisolated async function, so it runs
+ /// off the client actor and inherits the caller's cancellation.
+ ///
+ /// JSON decoding can't be interrupted once started, so cancellation is checked
+ /// before and after the work. A cancelled request never returns a value.
+ static func decode(_ type: T.Type, from data: Data) async throws -> T {
+ try Task.checkCancellation()
+ let value: T
+ do {
+ value = try JSONDecoder().decode(T.self, from: data)
+ } catch {
+ throw NexioError.decodingFailed(underlying: error, data: data)
+ }
+ try Task.checkCancellation()
+ return value
+ }
+}
+
+// MARK: - URLRedactor
+
+/// Removes secrets from URLs before they reach logs.
+///
+/// Query values are replaced with `REDACTED`, and user info and fragments are dropped.
+enum URLRedactor {
+ static func redacted(_ url: URL?) -> String {
+ guard let url = url,
+ var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
+ return ""
+ }
+ components.user = nil
+ components.password = nil
+ if let items = components.queryItems {
+ components.queryItems = items.map { URLQueryItem(name: $0.name, value: "REDACTED") }
+ }
+ components.fragment = nil
+ return components.string ?? ""
+ }
+}
diff --git a/Sources/Nexio/Core/NexioConfig.swift b/Sources/Nexio/Core/NexioConfig.swift
index 4baa6eb..ce19dc1 100644
--- a/Sources/Nexio/Core/NexioConfig.swift
+++ b/Sources/Nexio/Core/NexioConfig.swift
@@ -23,9 +23,9 @@ public struct NexioConfig: Sendable {
public enum LogLevel: Sendable {
/// No logging.
case none
- /// Logs only errors and non-2xx responses.
+ /// Logs failed request methods, sanitized URLs, and response status codes.
case errors
- /// Logs every request and response, including headers and bodies.
+ /// Logs every request method, sanitized URL, response status, and byte count.
case all
}
@@ -40,12 +40,20 @@ public struct NexioConfig: Sendable {
/// Headers added to every request. Individual requests can override or
/// extend these by passing their own `headers` dictionary.
+ ///
+ /// Don't put credentials here. Use ``AuthStrategy`` instead. An explicit
+ /// no-auth endpoint removes common credential headers from these defaults,
+ /// but custom credential header names are not detected.
public var defaultHeaders: [String: String] = [
"Content-Type": "application/json",
"Accept": "application/json"
]
/// Global retry policy. Defaults to ``RetryPolicy/none``.
+ ///
+ /// Applies to every request. Only idempotent methods are retried, so POST
+ /// and PATCH are never retried by default. Retries stop after a hard limit
+ /// of 10 retries per request.
public var retry: RetryPolicy = .none
/// Console log verbosity. Defaults to ``LogLevel/none``.
diff --git a/Sources/Nexio/Core/NexioRequest.swift b/Sources/Nexio/Core/NexioRequest.swift
index 9f5b204..cec5717 100644
--- a/Sources/Nexio/Core/NexioRequest.swift
+++ b/Sources/Nexio/Core/NexioRequest.swift
@@ -5,15 +5,18 @@ import Foundation
/// A handle to an in-flight Nexio request.
///
-/// Retain the handle if you need to cancel the request before it completes.
+/// - Note: No public Nexio API currently returns a `NexioRequest`. To cancel a
+/// request, cancel the `Task` that awaits it. Cancelling the task stops
+/// retries and the underlying network call, and the await throws
+/// `CancellationError`.
///
/// ```swift
-/// let handle = NexioRequest()
-/// Task {
-/// let users: [User] = try await NexioClient.shared.get("/users", requestHandle: handle)
+/// let task = Task {
+/// let users: [User] = try await NexioClient.shared.get("/users")
+/// return users
/// }
/// // Later:
-/// handle.cancel()
+/// task.cancel()
/// ```
public final class NexioRequest: Sendable {
diff --git a/Sources/Nexio/Image/ImageLoader.swift b/Sources/Nexio/Image/ImageLoader.swift
index f4d0896..542340e 100644
--- a/Sources/Nexio/Image/ImageLoader.swift
+++ b/Sources/Nexio/Image/ImageLoader.swift
@@ -18,6 +18,12 @@ public typealias PlatformImage = NSImage
extension NSImage: @retroactive @unchecked Sendable {}
#endif
+/// Carries a decoded image out of a detached task. Images are decoded once and
+/// never mutated afterwards, so sharing the reference is safe.
+private struct DecodedImage: @unchecked Sendable {
+ let image: PlatformImage
+}
+
// MARK: - ImageLoader
/// An actor that fetches and caches platform images.
@@ -35,6 +41,11 @@ public actor ImageLoader {
/// Shared instance used by ``NexioImage``.
public static let shared = ImageLoader()
+ // MARK: - Constants
+
+ /// Maximum number of images fetched at once by ``prefetch(_:)``.
+ private static let prefetchConcurrency = 4
+
// MARK: - Private State
private let session: URLSession
@@ -77,13 +88,17 @@ public actor ImageLoader {
if let httpError = response.nexioError(data: data) {
throw httpError
}
- guard let image = PlatformImage(data: data) else {
+ try Task.checkCancellation()
+ // Decoding is CPU work. decodeImage is nonisolated, so it runs off the actor.
+ let decoded = await Self.decodeImage(data)
+ try Task.checkCancellation()
+ guard let decoded else {
throw NexioError.decodingFailed(
underlying: ImageDecodingError.invalidImageData,
data: data
)
}
- return image
+ return decoded.image
}
/// Prefetches images in the background, populating the `URLCache`.
@@ -93,16 +108,26 @@ public actor ImageLoader {
/// - Parameter urls: URLs to warm up in the cache.
public func prefetch(_ urls: [URL]) async {
await withTaskGroup(of: Void.self) { group in
- for url in urls {
- // No [weak self] — structured concurrency keeps the actor alive
- // for the lifetime of the task group.
- group.addTask {
- _ = try? await self.load(url)
+ var remaining = urls.makeIterator()
+ // Keep at most `prefetchConcurrency` downloads in flight.
+ for _ in 0.. DecodedImage? {
+ PlatformImage(data: data).map { DecodedImage(image: $0) }
+ }
+
/// Removes all cached images from memory and disk.
public func clearCache() {
session.configuration.urlCache?.removeAllCachedResponses()
@@ -113,4 +138,4 @@ public actor ImageLoader {
private enum ImageDecodingError: Error {
case invalidImageData
-}
\ No newline at end of file
+}
diff --git a/Sources/Nexio/Image/NexioImage.swift b/Sources/Nexio/Image/NexioImage.swift
index 2c043f1..0503f46 100644
--- a/Sources/Nexio/Image/NexioImage.swift
+++ b/Sources/Nexio/Image/NexioImage.swift
@@ -91,8 +91,14 @@ public struct NexioImage: View {
}
do {
let image = try await loader.load(imageURL)
+ guard !Task.isCancelled else { return }
phase = .loaded(image)
+ } catch is CancellationError {
+ // The view moved on to another URL or disappeared. Leave the phase
+ // alone, so a cancelled load never shows the failure image.
+ return
} catch {
+ guard !Task.isCancelled else { return }
phase = .failed
}
}
diff --git a/Sources/Nexio/Interceptor/RetryInterceptor.swift b/Sources/Nexio/Interceptor/RetryInterceptor.swift
index e2eeb92..36f2539 100644
--- a/Sources/Nexio/Interceptor/RetryInterceptor.swift
+++ b/Sources/Nexio/Interceptor/RetryInterceptor.swift
@@ -5,29 +5,38 @@ import Foundation
// MARK: - RetryPolicy
-/// Configures how ``RetryInterceptor`` retries failed requests.
+/// Configures how Nexio retries failed requests.
+///
+/// Set it on ``NexioConfig/retry`` for the client's built-in retry, or pass it to
+/// ``RetryInterceptor`` for explicit control.
+///
+/// Only idempotent methods (GET, HEAD, PUT, DELETE, OPTIONS) are retried.
+/// POST and PATCH are never retried by default, because repeating them can
+/// duplicate work on the server.
+///
+/// Retried errors are network failures, timeouts, HTTP 429, and HTTP 5xx.
///
/// ```swift
/// var config = NexioConfig()
-/// config.retry = .standard // 3 attempts, exponential backoff
+/// config.retry = .standard // 3 retries (up to 4 requests), exponential backoff
/// ```
public struct RetryPolicy: Sendable {
/// No retries (default).
public static let none = RetryPolicy(maxAttempts: 0, backoff: .none)
- /// Three attempts with exponential backoff starting at 1 second.
+ /// Three retries with exponential backoff starting at 1 second.
public static let standard = RetryPolicy(maxAttempts: 3, backoff: .exponential(base: 1.0))
// MARK: - Nested Types
/// Delay strategy between retry attempts.
public enum Backoff: Sendable {
- /// No delay — retry immediately.
+ /// No delay. Retry immediately.
case none
/// Fixed delay of `seconds` between every retry.
case linear(seconds: TimeInterval)
- /// Delay doubles each attempt: `base × 2^attempt` (seconds).
+ /// Delay doubles each attempt: `base × 2^attempt` seconds.
case exponential(base: TimeInterval)
}
@@ -53,6 +62,10 @@ public struct RetryPolicy: Sendable {
// MARK: - Internal
+ /// Longest pause between attempts. Caps exponential growth and keeps the
+ /// nanosecond conversion inside `UInt64` range.
+ static let maximumDelay: TimeInterval = 60
+
/// Nanoseconds to sleep before attempt `attempt` (zero-based).
func delay(for attempt: Int) -> UInt64 {
let seconds: TimeInterval
@@ -64,7 +77,56 @@ public struct RetryPolicy: Sendable {
case .exponential(let base):
seconds = base * pow(2.0, Double(attempt))
}
- return UInt64(seconds * 1_000_000_000)
+ let clamped = min(max(seconds, 0), Self.maximumDelay)
+ return UInt64(clamped * 1_000_000_000)
+ }
+}
+
+// MARK: - Retry decision
+
+extension RetryPolicy {
+
+ /// Decides whether `request` should be retried after `error`.
+ ///
+ /// Sleeps for the backoff delay when it returns `true`. Returns `false`
+ /// immediately if the task is cancelled, including during the sleep.
+ ///
+ /// - Parameters:
+ /// - request: The request that failed.
+ /// - error: The failure to evaluate.
+ /// - attempt: Zero-based attempt counter.
+ /// - Returns: `true` to run another attempt.
+ func shouldRetry(_ request: URLRequest, dueTo error: NexioError, attempt: Int) async -> Bool {
+ guard maxAttempts > 0, attempt < maxAttempts else { return false }
+ guard Self.isIdempotent(request), Self.isRetryable(error) else { return false }
+
+ let nanoseconds = delay(for: attempt)
+ if nanoseconds > 0 {
+ do {
+ try await Task.sleep(nanoseconds: nanoseconds)
+ } catch {
+ return false // cancelled while waiting
+ }
+ }
+ return !Task.isCancelled
+ }
+
+ /// Whether repeating `request` is safe. A missing method means GET.
+ static func isIdempotent(_ request: URLRequest) -> Bool {
+ let method = (request.httpMethod ?? "GET").uppercased()
+ return ["GET", "HEAD", "PUT", "DELETE", "OPTIONS"].contains(method)
+ }
+
+ /// Whether `error` is transient.
+ static func isRetryable(_ error: NexioError) -> Bool {
+ switch error {
+ case .noInternet, .timeout:
+ return true
+ case .serverError(let code, _):
+ return code == 429 || (500...599).contains(code)
+ default:
+ return false
+ }
}
}
@@ -72,8 +134,9 @@ public struct RetryPolicy: Sendable {
/// An ``Interceptor`` that retries transient failures according to a ``RetryPolicy``.
///
-/// Only retries on `.noInternet`, `.timeout`, or 5xx `serverError`.
-/// Respects the configured ``RetryPolicy/backoff`` delay between attempts.
+/// Use it to add retry to a specific client, or to combine retry with other
+/// interceptors. For the client's own retry setting, use ``NexioConfig/retry``
+/// instead, so you don't end up with two retry layers.
///
/// ```swift
/// await NexioClient.shared.addInterceptor(RetryInterceptor(policy: .standard))
@@ -98,24 +161,6 @@ public struct RetryInterceptor: Interceptor {
dueTo error: NexioError,
attempt: Int
) async -> Bool {
- guard attempt < policy.maxAttempts, isRetryable(error) else { return false }
- let delay = policy.delay(for: attempt)
- if delay > 0 {
- try? await Task.sleep(nanoseconds: delay)
- }
- return true
- }
-
- // MARK: - Private
-
- private func isRetryable(_ error: NexioError) -> Bool {
- switch error {
- case .noInternet, .timeout:
- return true
- case .serverError(let code, _):
- return (500...599).contains(code)
- default:
- return false
- }
+ await policy.shouldRetry(request, dueTo: error, attempt: attempt)
}
-}
\ No newline at end of file
+}
diff --git a/Sources/Nexio/Internal/URLSessionExtensions.swift b/Sources/Nexio/Internal/URLSessionExtensions.swift
index 20605ca..86ba0ec 100644
--- a/Sources/Nexio/Internal/URLSessionExtensions.swift
+++ b/Sources/Nexio/Internal/URLSessionExtensions.swift
@@ -9,7 +9,9 @@ extension URLSession {
///
/// - Parameter request: The URL request to execute.
/// - Returns: Raw response data and the HTTP response.
- /// - Throws: ``NexioError`` on network failure or non-2xx status.
+ /// - Throws: ``NexioError`` on network failure, `CancellationError` if the task
+/// was cancelled. Non-2xx responses are returned, not thrown, so the caller
+/// can decide whether to retry.
func nexioData(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let (data, response): (Data, URLResponse)
do {
@@ -20,6 +22,10 @@ extension URLSession {
throw NexioError.noInternet
case .timedOut:
throw NexioError.timeout
+ case .cancelled:
+ // Cancellation is not a failure. Surface it as Swift's standard error,
+ // so callers and the image view can ignore it.
+ throw CancellationError()
default:
throw NexioError.unknown(underlying: urlError)
}
diff --git a/Tests/NexioTests/MockURLProtocol.swift b/Tests/NexioTests/MockURLProtocol.swift
index e0ea9e2..b2022a3 100644
--- a/Tests/NexioTests/MockURLProtocol.swift
+++ b/Tests/NexioTests/MockURLProtocol.swift
@@ -34,6 +34,12 @@ final class MockURLProtocol: URLProtocol, @unchecked Sendable {
/// Set this before each test. Nil causes a connection error.
nonisolated(unsafe) static var responseStub: Response?
+ /// Number of requests that reached the mock. Reset it at the start of each test.
+ nonisolated(unsafe) static var requestCount = 0
+
+ /// The most recent request that reached the mock, for header assertions.
+ nonisolated(unsafe) static var lastRequest: URLRequest?
+
// MARK: - Helpers
/// Stubs a JSON response from an `Encodable` value.
@@ -71,6 +77,8 @@ final class MockURLProtocol: URLProtocol, @unchecked Sendable {
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
+ MockURLProtocol.requestCount += 1
+ MockURLProtocol.lastRequest = request
guard let stub = MockURLProtocol.responseStub else {
client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet))
return
diff --git a/Tests/NexioTests/PipelineTests.swift b/Tests/NexioTests/PipelineTests.swift
new file mode 100644
index 0000000..cbc8adc
--- /dev/null
+++ b/Tests/NexioTests/PipelineTests.swift
@@ -0,0 +1,185 @@
+// Copyright (c) 2026 ANSCoder
+// Licensed under the MIT License. See LICENSE in the project root for details.
+
+import Testing
+import Foundation
+@testable import Nexio
+
+@Suite("Request pipeline", .serialized)
+struct PipelineTests {
+
+ private struct Empty: Codable, Sendable {}
+
+ private struct SecureEndpoint: Endpoint {
+ let auth: AuthStrategy?
+ var baseURL: URL { URL(string: "https://api.test")! }
+ var path: String { "/me" }
+ var method: HTTPMethod { .get }
+ }
+
+ private func makeClient(retry: RetryPolicy = .none) async -> NexioClient {
+ let client = NexioClient()
+ var config = NexioConfig()
+ config.protocolClasses = [MockURLProtocol.self]
+ config.retry = retry
+ await client.configure(config)
+ return client
+ }
+
+ // MARK: - Retry
+
+ @Test("Config retry retries 5xx and makes maxAttempts + 1 total requests")
+ func configRetryRetriesServerErrors() async throws {
+ try await mockingNetwork {
+ MockURLProtocol.requestCount = 0
+ MockURLProtocol.stub(statusCode: 503, data: Data())
+ let client = await makeClient(retry: RetryPolicy(maxAttempts: 2, backoff: .none))
+
+ do {
+ let _: Empty = try await client.get("https://api.test/flaky")
+ Issue.record("Expected serverError after retries")
+ } catch NexioError.serverError(let code, _) {
+ #expect(code == 503)
+ }
+ #expect(MockURLProtocol.requestCount == 3)
+ }
+ }
+
+ @Test("POST is never retried by default, even on 503")
+ func postIsNotRetried() async throws {
+ try await mockingNetwork {
+ MockURLProtocol.requestCount = 0
+ MockURLProtocol.stub(statusCode: 503, data: Data())
+ let client = await makeClient(retry: RetryPolicy(maxAttempts: 3, backoff: .none))
+
+ do {
+ let _: Empty = try await client.post("https://api.test/orders", body: Empty())
+ Issue.record("Expected serverError")
+ } catch NexioError.serverError(_, _) {
+ // expected
+ }
+ #expect(MockURLProtocol.requestCount == 1)
+ }
+ }
+
+ @Test("Reconfiguring with retry disabled turns retry off")
+ func reconfigureDisablesRetry() async throws {
+ try await mockingNetwork {
+ MockURLProtocol.requestCount = 0
+ MockURLProtocol.stub(statusCode: 503, data: Data())
+ let client = await makeClient(retry: RetryPolicy(maxAttempts: 2, backoff: .none))
+ var config = NexioConfig()
+ config.protocolClasses = [MockURLProtocol.self]
+ config.retry = .none
+ await client.configure(config)
+
+ do {
+ let _: Empty = try await client.get("https://api.test/flaky")
+ Issue.record("Expected serverError")
+ } catch NexioError.serverError(_, _) {
+ // expected
+ }
+ #expect(MockURLProtocol.requestCount == 1)
+ }
+ }
+
+ // MARK: - Auth
+
+ @Test("Endpoint auth replaces the dynamic AuthInterceptor token")
+ func endpointAuthWins() async throws {
+ try await mockingNetwork {
+ MockURLProtocol.requestCount = 0
+ MockURLProtocol.stub(statusCode: 200, json: "{}")
+ let client = await makeClient()
+ await client.addInterceptor(AuthInterceptor { .bearer("dynamic") })
+
+ let _: Empty = try await client.request(SecureEndpoint(auth: .bearer("endpoint")))
+
+ #expect(MockURLProtocol.lastRequest?.value(forHTTPHeaderField: "Authorization") == "Bearer endpoint")
+ }
+ }
+
+ @Test("Endpoint auth .some(.none) sends no token even with a dynamic provider")
+ func noneEndpointSendsNoToken() async throws {
+ try await mockingNetwork {
+ MockURLProtocol.requestCount = 0
+ MockURLProtocol.stub(statusCode: 200, json: "{}")
+ let client = await makeClient()
+ await client.addInterceptor(AuthInterceptor { .bearer("dynamic") })
+
+ let _: Empty = try await client.request(SecureEndpoint(auth: .some(.none)))
+
+ #expect(MockURLProtocol.lastRequest?.value(forHTTPHeaderField: "Authorization") == nil)
+ }
+ }
+
+ @Test("Requests without an endpoint override still get the dynamic token")
+ func dynamicTokenStillApplied() async throws {
+ try await mockingNetwork {
+ MockURLProtocol.requestCount = 0
+ MockURLProtocol.stub(statusCode: 200, json: "{}")
+ let client = await makeClient()
+ await client.addInterceptor(AuthInterceptor { .bearer("dynamic") })
+
+ let _: Empty = try await client.get("https://api.test/me")
+
+ #expect(MockURLProtocol.lastRequest?.value(forHTTPHeaderField: "Authorization") == "Bearer dynamic")
+ }
+ }
+
+ @Test("Explicit no-auth strips credentials from default headers")
+ func noneStripsDefaultCredentialHeaders() async throws {
+ try await mockingNetwork {
+ MockURLProtocol.requestCount = 0
+ MockURLProtocol.stub(statusCode: 200, json: "{}")
+ let client = NexioClient()
+ var config = NexioConfig()
+ config.protocolClasses = [MockURLProtocol.self]
+ config.defaultHeaders["Authorization"] = "Bearer leaked"
+ config.defaultHeaders["X-Api-Key"] = "leaked-key"
+ config.defaultHeaders["Cookie"] = "session=leaked"
+ await client.configure(config)
+
+ let _: Empty = try await client.request(SecureEndpoint(auth: .some(.none)))
+
+ let sent = MockURLProtocol.lastRequest
+ #expect(sent?.value(forHTTPHeaderField: "Authorization") == nil)
+ #expect(sent?.value(forHTTPHeaderField: "X-Api-Key") == nil)
+ #expect(sent?.value(forHTTPHeaderField: "Cookie") == nil)
+ #expect(sent?.httpShouldHandleCookies == false)
+ }
+ }
+
+ @Test("Explicit no-auth strips credentials from endpoint headers")
+ func noneStripsEndpointCredentialHeaders() async throws {
+ try await mockingNetwork {
+ MockURLProtocol.requestCount = 0
+ MockURLProtocol.stub(statusCode: 200, json: "{}")
+ let client = await makeClient()
+
+ struct HeaderEndpoint: Endpoint {
+ var baseURL: URL { URL(string: "https://api.test")! }
+ var path: String { "/status" }
+ var method: HTTPMethod { .get }
+ var headers: [String: String] { ["Authorization": "Bearer inline"] }
+ var auth: AuthStrategy? { .some(.none) }
+ }
+
+ let _: Empty = try await client.request(HeaderEndpoint())
+
+ #expect(MockURLProtocol.lastRequest?.value(forHTTPHeaderField: "Authorization") == nil)
+ }
+ }
+
+ @Test("Log redaction hides query values, user info, and fragments")
+ func redactsQueryValues() {
+ let url = URL(string: "https://user:pw@api.test/users?access_token=abc123&page=2&Signature=xyz#secret-fragment")
+ let text = URLRedactor.redacted(url)
+ #expect(!text.contains("abc123"))
+ #expect(!text.contains("xyz"))
+ #expect(!text.contains("pw@"))
+ #expect(!text.contains("secret-fragment"))
+ #expect(text.contains("page=REDACTED"))
+ #expect(text.contains("REDACTED"))
+ }
+}