Lightweight, actor-based Swift networking SDK
Typed JSON requests · Auth strategies · Retry with backoff · Interceptor pipeline · SwiftUI image loading · Zero dependencies
- Actor-based —
NexioClientis a Swift actor; zero data-race risk, safe to call from any concurrency context - Typed requests —
get,post,put,patch,deletereturn decodedDecodablevalues directly - Type-safe endpoints —
Endpointprotocol for grouping request details in reusable structs - Auth strategies — bearer token, API key, custom headers, or dynamic provider (OAuth refresh)
- Interceptor pipeline — adapt requests and retry failures with full control
- Retry with backoff — none, linear, or exponential backoff; configurable per policy
- SwiftUI image loading —
NexioImagedrop-in forAsyncImagewithURLCache-backed caching and prefetch - Structured errors —
NexioErrorcovers network, auth, 4xx/5xx, and decoding failures - Zero dependencies — pure Swift, built on
URLSession
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.
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.
flowchart LR
Core["Core<br/>NexioClient, NexioConfig, Endpoint"] --> Interceptor["Interceptor<br/>Interceptor, RetryInterceptor"]
Core --> Auth["Auth<br/>AuthStrategy, AuthInterceptor"]
Core --> Errors["Errors<br/>NexioError"]
Core --> Internal["Internal<br/>URLSession and HTTP helpers"]
Image["Image<br/>ImageLoader, NexioImage"] --> Internal
Auth --> Interceptor
Every request goes through the same loop. Retries re-enter it from the top, so each attempt gets fresh adaptation, including a fresh token.
flowchart TD
App["App code<br/>get, post, request(endpoint)"] --> Build["Build URLRequest<br/>baseURL, headers, auth"]
Build --> Adapt["Adapt<br/>interceptors in order<br/>AuthInterceptor skipped when endpoint sets auth"]
Adapt --> Send["URLSession.data(for:)"]
Send --> Status{"Transport error<br/>or 429 / 5xx?"}
Status -- "no, 2xx or 4xx" --> Decode["Decode JSON<br/>off the actor"]
Status -- "yes" --> Retry{"Built-in retry allowed?<br/>idempotent, retryable,<br/>under attempt limit"}
Retry -- "yes, after backoff" --> Adapt
Retry -- "no" --> Throw["Throw NexioError<br/>or CancellationError"]
Decode --> Done["Return value"]
Throw --> App
Done --> App
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.
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
| 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. |
// Package.swift
dependencies: [
.package(url: "https://github.com/ANSCoder/Nexio", from: "1.0.0")
],
targets: [
.target(name: "YourApp", dependencies: [
.product(name: "Nexio", package: "Nexio")
])
]Or add it in Xcode: File → Add Package Dependencies, paste the repo URL.
// AppDelegate / App.init
var config = NexioConfig()
config.baseURL = URL(string: "https://api.example.com")
config.timeout = 15
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"))// GET — decodes JSON automatically
let users: [User] = try await NexioClient.shared.get("/users")
// POST with body
let body = CreateUserRequest(name: "Alice")
let created: User = try await NexioClient.shared.post("/users", body: body)
// PUT / PATCH / DELETE
let updated: User = try await NexioClient.shared.put("/users/42", body: changes)
try await NexioClient.shared.delete("/users/42")
// Absolute URLs work too (ignores baseURL)
let user: User = try await NexioClient.shared.get("https://api.example.com/users/1")// Shorthand for NexioClient.shared.get / .post
let users: [User] = try await nexioGet("/users")
let created: User = try await nexioPost("/users", body: newUser)// Static bearer token
await NexioClient.shared.setAuth(.bearer("jwt-token"))
// API key in a custom header
await NexioClient.shared.setAuth(.apiKey(header: "X-Api-Key", value: "secret"))
// Arbitrary headers
await NexioClient.shared.setAuth(.custom(["X-Tenant-ID": "acme", "X-Version": "2"]))
// Dynamic token — closure called before every request (ideal for OAuth refresh)
let authInterceptor = AuthInterceptor {
await TokenStore.shared.currentToken() // returns AuthStrategy
}
await NexioClient.shared.addInterceptor(authInterceptor)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) } // 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.
Group URL, method, query params, headers, and body in one reusable struct:
struct GetUser: Endpoint {
let id: Int
var baseURL: URL { URL(string: "https://api.example.com")! }
var path: String { "/users/\(id)" }
var method: HTTPMethod { .get }
}
struct SearchUsers: Endpoint {
let query: String
var baseURL: URL { URL(string: "https://api.example.com")! }
var path: String { "/users/search" }
var method: HTTPMethod { .get }
var queryItems: [URLQueryItem] { [URLQueryItem(name: "q", value: query)] }
}
let user: User = try await NexioClient.shared.request(GetUser(id: 42))
let results: [User] = try await NexioClient.shared.request(SearchUsers(query: "alice"))// 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 composing with your own interceptors
await NexioClient.shared.addInterceptor(
RetryInterceptor(policy: .standard)
)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.
Implement Interceptor to hook into every request:
struct LoggingInterceptor: Interceptor {
func adapt(_ request: URLRequest, for session: URLSession) async throws -> URLRequest {
print("→ \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
return request
}
func retry(_ request: URLRequest, dueTo error: NexioError, attempt: Int) async -> Bool {
false
}
}
await NexioClient.shared.addInterceptor(LoggingInterceptor())Interceptors run in insertion order during adapt, and reverse order during retry.
All errors are typed as NexioError:
do {
let user: User = try await NexioClient.shared.get("/users/1")
} catch NexioError.unauthorized {
// Redirect to login
} catch NexioError.notFound {
// Show 404 UI
} catch NexioError.noInternet {
// Show offline banner
} catch NexioError.serverError(let statusCode, let data) {
// Handle 5xx
} catch NexioError.decodingFailed(let underlying, let data) {
// Log raw response for debugging
print(String(data: data, encoding: .utf8) ?? "")
} catch NexioError.invalidURL(let string) {
// Bad URL at call site
}NexioImage is a drop-in replacement for AsyncImage with transparent URLCache caching (50 MB memory / 200 MB disk by default):
// Default — gray placeholder, photo icon on failure
NexioImage("https://cdn.example.com/photo.jpg")
.frame(width: 100, height: 100)
.clipShape(Circle())
// Custom placeholder and failure views
NexioImage(
"https://cdn.example.com/photo.jpg",
placeholder: { ProgressView() },
failureImage: { Image(systemName: "person.crop.circle.fill") }
)
// Prefetch a list for smoother scroll performance
let urls = items.compactMap { URL(string: $0.imageURL) }
await ImageLoader.shared.prefetch(urls)
// Clear cache
await ImageLoader.shared.clearCache()NexioClient is a Swift actor — all state mutations are serialized automatically with no extra effort. In-flight network I/O runs concurrently through URLSession's connection pool:
// Three requests in flight simultaneously
async let users: [User] = NexioClient.shared.get("/users")
async let posts: [Post] = NexioClient.shared.get("/posts")
async let comments: [Comment] = NexioClient.shared.get("/comments")
let (u, p, c) = try await (users, posts, comments)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:
let task = Task {
let posts: [Post] = try await NexioClient.shared.get("/feed")
return posts
}
task.cancel() // stops retries and the network callInject a custom URLProtocol subclass via NexioConfig.protocolClasses to stub responses without hitting the network:
var config = NexioConfig()
config.protocolClasses = [MockURLProtocol.self]
await client.configure(config)| Platform | Minimum |
|---|---|
| iOS | 16.0 |
| macOS | 13.0 |
| watchOS | 9.0 |
| tvOS | 16.0 |
Swift 6.2+ · Zero external dependencies
Nexio is released under the MIT license. See LICENSE for details.
