NovaNetworkClient is a Swift library for deduplicating concurrent requests with the same logical identity.
When multiple callers ask for the same resource at the same time, only one underlying operation runs and all callers await the shared result.
- Getting Started
- Interactive DocC Tutorials
- Documentation Website
- Website source and build instructions
- Core Concepts
- Choosing an API
- Declarative Endpoints (
@Endpointmacro and OpenAPI generation) - Record and Replay (cassettes)
- Diagnostics (recorder, HAR export, Instruments, SwiftUI panel)
- Authentication (OAuth 2.0, PKCE, token storage, request signing)
- Query Layer (server state for screens)
- Offline-First Apps (offline queue or your own database?)
- Production Checklist
- Changelog
- Contributing
- Setup & Usage Guide
- Unit Test Policy
- Code of Conduct
- Security Policy
- Examples
- Telemetry Contract v2
- Documentation Experience DFR
- NovaNetwork v2 DFR
- NovaNetwork v2.1 DFR
- NovaNetwork v2.11 DFR
- NovaNetwork v2.12 DFR
- NovaNetwork v2.13 DFR
- NovaNetwork v2.14 DFR
- NovaNetwork v3.0 DFR
- NovaNetwork v3.1 DFR
- NovaNetwork v3.2 DFR
- NovaNetwork v3.3 DFR
- NovaNetwork v3.4 DFR
- NovaNetwork v3.5 DFR
- NovaNetwork v3.6 DFR
- v1.19 Traceability Pack
- v2.0 Traceability Pack
- v2.1 Traceability Pack
- v2.11 Traceability Pack
- v2.12 Traceability Pack
- v2.13 Traceability Pack
- v2.14 Traceability Pack
- v3.0 Traceability Pack
- v3.1 Traceability Pack
- v3.2 Traceability Pack
- v3.3 Traceability Pack
- v3.4 Traceability Pack
- v3.5 Traceability Pack
- v3.6 Traceability Pack
dependencies: [
.package(url: "https://github.com/maxches99/NovaNetwork", from: "2.0.0")
]targets: [
.target(
name: "YourTarget",
dependencies: [
.product(name: "NovaNetworkClient", package: "NovaNetwork")
]
)
]The package has no dependencies in this configuration.
The @Endpoint macro is the only part of the package that needs swift-syntax, so it lives behind
a SwiftPM trait that is off by default. SwiftPM prunes the dependency entirely while the trait is
disabled, so opting out costs nothing:
dependencies: [
.package(url: "https://github.com/maxches99/NovaNetwork", from: "2.11.0", traits: ["EndpointMacros"])
].product(name: "NovaNetworkMacros", package: "NovaNetwork")Generating endpoints from an OpenAPI document needs neither the trait nor the macro — see Declarative Endpoints.
- Typed
Endpoint<Response>execution with endpoint-specific decoding. - Declarative endpoints: the opt-in
@Endpointmacro generates request construction from a method, a path template, and a type's stored properties, with@Path/@Query/@Header/@Bodymarkers and compile-time diagnostics for every misuse. - OpenAPI 3.0/3.1 code generation (
swift package nova-openapi) producing deterministic, checked-in endpoint and model types that depend only onNovaNetworkCore— no macro, no swift-syntax. - Shared declarative runtime (
EndpointDefinition,EndpointRequestBuilder,EndpointParameterConvertible) so hand-written, macro-generated, and spec-generated endpoints build identical requests. - Record and replay (
NovaNetworkCassette): capture real exchanges into a reviewable JSON cassette and replay them deterministically in tests, previews, and offline demo builds, with credentials redacted before anything reaches disk. - Diagnostics (
NovaNetworkDiagnostics): a bounded recorder over the existing telemetry, retry waterfalls, HAR 1.2 export for bug reports,os_signpostintervals for Instruments, and a SwiftUI panel — with credentials redacted before anything is retained. - Linux build gate:
NovaNetworkClient's Apple-only APIs (certificate pinning, mTLS, the offline queue's optional AES-GCM cipher) are behind explicit availability checks, and fingerprint hashing no longer requiresCryptoKit, so the core client compiles on Linux (see Linux Compatibility for exactly what that does and doesn't claim). NetworkErrorconforms toEquatableandLocalizedError;NetworkErrorContextandContextualNetworkErrorattach request/attempt context without changing its existing cases.NovaNetworkClientTestSupport: request-matching routes, chaos injection, a deterministic virtual clock, and telemetry recording for testing code that usesNovaNetworkClient.ResponseDecodingstrategies (decode,loadResponse) for decoders that need response headers, includingContent-Type-based negotiation, alongside the unchanged defaultDecodable/JSONDecoderpath.NetworkClientConfiguration: a mutable value grouping every construction option, as an alternative toNetworkClient.init's labeled arguments for configuring many options at once.- Swift 6.2 strict-concurrency compliance and a Swift 6.3 compatibility CI lane.
- Bounded concurrent batches with stable ordering, fail-fast, and collecting modes.
- Incremental response streaming plus native URLSession upload/download progress APIs.
- Durable managed transfers with stable IDs, resumable HTTP Range downloads, TUS uploads, integrity checks, restoration, and Apple background URLSession coordination.
- Single-flight HTTP authentication refresh, isolated by authentication scope.
- Authentication (
NovaNetworkAuth): OAuth 2.0 authorization code with PKCE, refresh, client credentials, and device grants; typed error envelopes; in-memory and Keychain token stores; and HMAC-SHA256 request signing, wired into the client's existing single-flight refresh. - Standalone cross-platform
NovaNetworkCoreproduct for request/response/error/endpoint models. - HTTP Cache 2.0 with ETag and Last-Modified revalidation, corrected age, request directives,
stale-if-error, and safeVary: *handling. - Query layer (
NovaNetworkQuery): server state by key for the screens that render it, with stale-while-revalidate reads, shared in-flight fetches, subscriptions, optimistic mutations with rollback, hierarchical invalidation, and paged queries. - Coalesces concurrent requests by stable fingerprint key.
- Optional short-lived in-memory response cache (
cacheFirst,staleWhileRevalidate). - Optional pluggable response cache (
MemoryResponseCache,DiskResponseCache, customResponseCache). - HTTP cache revalidation returns cached content after
304 Not Modified. - Configurable fingerprint policy (
query,headers,body). - Coalescer limits (
maxInFlightKeys,maxWaitersPerKey,inFlightTimeout). - Configurable waiter overflow behavior for coalescing limits (
bypassor explicit failure). - Per-request execution options (priority, per-request limits override, capacity scheduling, deadline budget, coalescing mode, circuit breaker).
- Client-side per-key rate limiting (
RateLimitPolicy). - Request middleware pipeline (
beforeSend/afterResponse). - Offline queue for write requests (
POST/PUT/PATCH) with durable disk store and replay APIs. - Offline-first sync pipeline: priority-aware replay (
critical/normal/background), fairness scheduler, starvation protection, replay windows, and rate controls. - Conflict workflows with rich metadata resolver hooks plus manual-review requeue API.
- Offline pipeline observability APIs (
offlineQueuePipelineMetrics) and recovery-loss telemetry. - Encrypted offline store lifecycle controls with forward-compatible schema reads and key-rotation rewrite path.
- Cancellation policies:
keepRunningcancelWhenNoWaiters
- Retry/backoff policy for transient failures (for example
429,5xx, network timeouts) with idempotency-aware gating. - Adaptive retry options (failure-category profiles,
Retry-Aftersupport, retry budget). - Runtime policy updates (global/host/endpoint scope) without recreating
NetworkClient. - Coalescing dedupe TTL policy with scope overrides and priority
endpoint > host > global. - Runtime tuning for coalescer fairness scheduler and circuit-breaker thresholds/probe policy.
- DX presets for faster adoption:
NetworkClientPreset.restHeavyNetworkClientPreset.realtimeHeavyNetworkClientPreset.offlineFirst
- Presets v2 composition model (
base preset + overlays) via:NetworkClientPreset.compose(base:overlays:)NetworkClientPresetOverlayKind
- Safe preset override points via
NetworkClientPreset.RequestOverrides(merge-only overrides). - Production onboarding helpers:
NetworkClientProductionProfileGeneratorNetworkClientPresetValidator/validateProductionReadiness- anti-pattern validation report with blocking vs warning findings.
- Testable retry behavior via injectable clock and random generator.
- Data and typed
Decodableloading APIs. - Typed error mapping overloads (
errorMapper). - Batch loading helpers (
loadBatch,loadBatchResults) with stable input order. - Streaming helper (
loadStream) with true incremental URLSession delivery on supported platforms. - Server-Sent Events (
loadServerSentEvents) with a spec-compliant, platform-independent parser, automatic reconnect,Last-Event-IDreplay, and server-drivenretry:timing. - Multipart form-data uploads (
uploadMultipart,MultipartFormDataEncoder) streamed from disk in fixed-size chunks, never buffering file parts fully in memory. - File-based uploads (
upload(request:fromFile:...)) streamed natively from disk viaURLSession.upload(for:fromFile:). - Certificate pinning (
Transport.pinned,CertificatePinningPolicy) with SPKI SHA-256 public key pins, backup pins for rotation, per-host allow/reject for unpinned hosts, and mutual TLS client certificate support (Apple platforms only). - Request helpers (
APIRequestBuilder,EncodableJSON body initializer). - Idempotency helpers (
APIRequest.withIdempotencyKey,IdempotencyPolicy). - Cache management (
preload,invalidate). - Coalescer metrics (
hit/miss/cancellation/completion) and observer events. - Async event stream (
events() -> AsyncStream<NetworkClientEvent>). - In-flight diagnostics (
inFlightRequests()). - Optional telemetry hooks for tracing/metrics adapters, including coalescer, queue metrics, retry, retry exhaustion, cancellation, circuit-breaker transitions, and offline queue lifecycle callbacks.
- Extended telemetry fields for retry schedule source/profile/scope, retry-skipped reasons, and runtime policy update events.
import Foundation
import NovaNetworkClient
let client = NetworkClient(
transport: Transport(),
retryPolicy: RetryPolicy(maxAttempts: 3)
)
let request = APIRequest(
method: .get,
url: URL(string: "https://example.com/api")!,
queryItems: [URLQueryItem(name: "id", value: "42")],
headers: ["Accept": "application/json"]
)
async let first = client.load(request: request, authScope: "user:42")
async let second = client.load(request: request, authScope: "user:42")
let (a, b) = try await (first, second)
print(a == b) // trueNetworkClient.init takes each option as a labeled argument, which reads well for a handful of
options. For configuring many options at once, build a NetworkClientConfiguration instead:
var configuration = NetworkClientConfiguration()
configuration.transport = Transport()
configuration.retryPolicy = RetryPolicy(maxAttempts: 3)
configuration.defaultCachePolicy = .cacheFirst(maxAge: 30)
configuration.middlewares = [authMiddleware]
configuration.telemetryHooks = hooks
configuration.addMiddleware(loggingMiddleware)
let client = NetworkClient(configuration: configuration)The two initializers are equivalent and interchangeable; NetworkClientConfiguration is a plain
mutable value, so a base configuration can be built once and adapted per environment (for
example, per build configuration or test target) before constructing a client from it.
import Foundation
import NovaNetworkClient
struct User: Decodable, Sendable {
let id: Int
let name: String
}
let endpoint = AnyEndpoint<User>(
request: APIRequest(
method: .get,
url: URL(string: "https://api.example.com/users/42")!
)
)
let client = NetworkClient(
httpAuthRefreshProvider: HTTPAuthRefreshProvider { scope in
let token = try await credentials.refreshToken(for: scope)
return ["Authorization": "Bearer \(token)"]
}
)
let user = try await client.execute(endpoint: endpoint, authScope: "user:42")
let bodies = try await client.loadBatch(
requests: requests,
authScope: "user:42",
batchOptions: .init(maxConcurrentRequests: 4)
)import Foundation
import NovaNetworkClient
let transferRoot = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
)[0].appendingPathComponent("Transfers", isDirectory: true)
let journal = DiskTransferJournal(
directoryURL: transferRoot.appendingPathComponent("journal", isDirectory: true)
)
let manager = ManagedTransferManager(
journal: journal,
partialDirectoryURL: transferRoot.appendingPathComponent("partial", isDirectory: true)
)
let handle = try await manager.startDownload(
request: APIRequest(
method: .get,
url: URL(string: "https://cdn.example.com/archive.zip")!,
headers: ["Authorization": "Bearer live-only"]
),
to: transferRoot.appendingPathComponent("archive.zip"),
destinationPolicy: .replace,
options: .init(
resume: .requiresValidator,
integrity: .expectedSHA256(expectedDigest)
)
)
for await event in handle.events {
if case .completed(let snapshot) = event {
print("Completed \(snapshot.id)")
}
}Call manager.restore() after relaunch, then pass a fresh authenticated APIRequest to
resumeDownload or resumeUpload. Request headers and credentials are deliberately not stored
in the journal. ManagedTransferManager uses the built-in TUS 1.0 strategy by default for
resumable uploads, or accepts a custom ResumableUploadStrategy.
For Apple background transfers, create a BackgroundTransferCoordinator, use a stable unique
background session identifier, and forward the host lifecycle callback:
let background = BackgroundTransferCoordinator(journal: journal)
let handle = try await background.scheduleDownload(
request: request,
to: destinationURL,
options: .init(
execution: .background(sessionIdentifier: "com.example.app.transfers"),
integrity: .expectedByteCount(expectedBytes),
networkPolicy: .init(
allowsCellularAccess: false,
isDiscretionary: true
)
)
)
// Forward from the app delegate's background URLSession callback.
await background.handleEvents(
forSessionIdentifier: identifier,
completionHandler: completionHandler
)The app still owns platform entitlements and lifecycle forwarding. Background execution is
available on iOS and macOS; other platforms receive
ManagedTransferError.backgroundTransfersUnavailable.
For model-only targets, depend on and import NovaNetworkCore. Existing targets may continue
to import NovaNetworkClient, which re-exports the core public API.
Two ways to stop hand-writing makeRequest(). Both produce ordinary Endpoint conformances, both
go through the same EndpointRequestBuilder, and they can be mixed with hand-written endpoints in
one target.
Requires the opt-in EndpointMacros trait — see Installation.
import NovaNetworkMacros
protocol PetstoreAPI: EndpointDefinition {}
extension PetstoreAPI {
var baseURL: URL { URL(string: "https://api.petstore.example.com/v1")! }
}
@Endpoint(.get, "/pets/{petId}/photos", response: [Photo].self)
struct GetPetPhotos: PetstoreAPI {
let petId: Int // fills {petId}: the name matches the placeholder
var limit: Int? // unmarked, so it becomes ?limit=…
@Query("sort_by") var sortBy: String?
@Header("X-Trace") var trace: String?
}
@Endpoint(.post, "/pets", response: Pet.self)
struct CreatePet: PetstoreAPI {
@Body var pet: NewPet
@Header("Idempotency-Key") var idempotencyKey: String
}
let photos = try await client.execute(endpoint: GetPetPhotos(petId: 7, limit: 20), authScope: "petstore")Each stored property takes one role, resolved in this order: an explicit @Path/@Query/@Header/
@Body marker, then a path parameter when the property name matches a {placeholder}, then a query
item under its own name. baseURL, timeout, additionalHeaders, and jsonEncoder are protocol
customization points and are never parameters; static and computed properties are ignored.
Behavior worth knowing, and identical for every declarative endpoint:
- a
nilquery item or header is omitted, never sent as the text"nil"; anilpath parameter is an error, since omitting it would change which resource the URL addresses; - arrays repeat by default (
?tag=a&tag=b);@Query("tag", style: .commaSeparated)sends?tag=a,b, alongside.spaceDelimitedand.pipeDelimited; - path values are percent-encoded against the unreserved set, so a value containing
/,?, or#stays one segment.
Misuse is a diagnostic on the offending declaration, not a malformed expansion: a non-struct or
generic type, an interpolated path, an unfilled {placeholder}, a @Path with no matching
placeholder, two @Body properties, @Body on GET/HEAD, two markers on one property, or an
empty wire name.
swift package --allow-writing-to-package-directory nova-openapi \
--spec openapi.yaml --output Sources/MyApp/GeneratedEndpoints.swiftThe generator reads OpenAPI 3.0.x and 3.1.x as JSON or as a documented YAML subset, and writes one
struct per operation plus Codable models from components/schemas. Output is deterministic —
the same document always produces the same bytes — and depends only on NovaNetworkCore, so it
compiles with the macro trait disabled. Generated names come from operationId, or from the method
and path when there is none (GET /pets/{petId}/photo → GetPetsByPetIdPhoto).
let pets = try await client.execute(
endpoint: ListPets(limit: 20, tags: ["kitten"]),
authScope: "petstore",
decoder: PetstoreAPI.makeDecoder() // configured for the document's date format
)Anything the generator cannot represent faithfully is a warning naming its location, never a silent
substitution: a multi-branch oneOf/anyOf falls back to a generated JSON value type, a recursive
schema is boxed as Indirect<…>, a payload with no JSON media type is skipped, and a document
without servers produces endpoints that take baseURL as an initializer parameter. Anchors,
aliases, tags, and multi-document YAML streams are rejected with a line and column rather than
guessed at. Credentials from securitySchemes never reach generated code.
A complete worked example — spec, checked-in output, and a runnable program — is in
Examples/OpenAPIPetstore.
Capture real traffic once, replay it forever. A cassette is a fixture with the fidelity of the real exchange — the header casing, the null field, the error envelope nobody remembered — and the determinism of a file.
import NovaNetworkCassette
try await withCassette(at: fixtureURL, upstream: Transport()) { transport in
let client = NetworkClient(transport: transport)
let user: User = try await client.load(request: request, authScope: nil)
#expect(user.name == "Ada")
}The first run performs the real request and writes the cassette; every run after is offline and
deterministic. CassetteTransport is an ordinary NetworkTransport, so coalescing, caching, retry,
middleware, and telemetry behave exactly as they do against a live server.
| Mode | Upstream contacted | Use it for |
|---|---|---|
.replay |
never | tests and demo builds that must not touch the network |
.record |
always | capturing a fresh recording |
.recordMissing |
only for unrecorded requests | day-to-day work: record once, replay after |
Matching defaults to method plus full URL, with query order ignored. Headers and bodies are
available but off by default: matching a header that carries a nonce or a trace id makes every
replay fail for a reason invisible in the file. Use .methodAndPath, .includingBody, or
.matchingHeaders("Accept-Language") when the identity really is different.
Repeated requests replay as episodes — first recording consumed first — so a polling or
pagination sequence stays a sequence. When they run out, the default errors; .repeatLast keeps
serving the final one.
Credentials never reach the file. Redaction runs as the exchange is captured, not as the file is
written. Authorization, Proxy-Authorization, Cookie, Set-Cookie, X-API-Key, and
X-Auth-Token are replaced by default; add your own headers, query items, or a body transform:
let redaction = CassetteRedaction.default
.redacting(headers: "X-Tenant-Signature")
.redacting(queryItems: "api_key")The file is pretty-printed JSON with sorted keys, UTF-8 bodies stored as text, and no timestamps, so re-saving an unchanged recording produces identical bytes and a real change shows up as a real diff.
NovaNetworkCassette is its own product depending only on NovaNetworkCore, so a preview or demo
build links it without a test-support module. Only completed HTTP exchanges are recorded — a
throwing transport is not an exchange — and streaming, SSE, and managed transfers are out of scope.
A runnable end-to-end demo is in Examples/Cassette.
The client already reports everything worth knowing. DiagnosticsRecorder is somewhere for it to
land:
import NovaNetworkDiagnostics
let recorder = DiagnosticsRecorder()
var configuration = NetworkClientConfiguration()
configuration.telemetryHooks = recorder.hooks
let client = NetworkClient(configuration: configuration)
recorder.startConsuming(client.events())That is the whole installation — diagnostics is a consumer of the existing telemetry contract, so nothing about the client changes.
One record per logical request, not per attempt. The client reports a start and an end for every attempt and announces a retry only after the failed attempt ended; the recorder stitches those back into the request a person actually made:
GET /flaky — 200 in 598 ms
█████████████ Attempt 1
············ Backoff 185 ms
██████████████████████████ Attempt 2
·························· Backoff 399 ms
█ Attempt 3
A record also carries the status or error, whether the request was coalesced, how the cache answered, redacted headers, and body sizes.
Failure means failure. A transport that returns a 500 as a response has completed the exchange —
so the outcome case is completed(status:), and isFailure covers any status from 400 up, a
transport error, or a cancellation. failureRate counts HTTP errors; a summary reading "0% failed"
next to a list of 500s would be worse than no summary.
Bounded by construction. 200 records by default, oldest dropped first, bodies capped at 64 KB and flagged when truncated. Credentials are redacted as a record is built, not as it is exported.
let har = try await recorder.exportHAR() // HAR 1.2 — opens in any browser's inspector
let summary = await recorder.summary() // 4 requests · 50% failed · 25% coalescedWith emitsSignposts enabled, requests become os_signpost intervals with retries as point events,
so Instruments shows request spans next to CPU and allocations. NetworkDiagnosticsView presents the
same data on Apple platforms; everything it shows is computed by DiagnosticsPanelState, which is
plain Swift and testable anywhere.
This is a development and support tool, not production monitoring — OpenTelemetryAdapter remains
the path to a backend.
A runnable demo, including the waterfall above, is in Examples/Diagnostics,
and DemoApp is an iOS app that puts the panel on a device: five scenarios covering
retries, coalescing, cache, rejection, and cancellation, run either against a scripted transport
inside the app or as real HTTPS requests to an httpbin-compatible host.
A waterfall says where one request's time went. It cannot say what else was running — and
concurrency, coalescing, and queueing are properties of requests together. DiagnosticsTimeline
places every retained record on one window with a readable ruler, and the panel switches to it:
2.1 s total 0 ms 500 ms 1 s 1.5 s
GET /flaky ▐█▌──▐█▌────▐█▌
GET /profile ▐██▌
GET /settings ▐██▌
POST /orders ▐█▌
GET /slow ▐████████ in flight
Two callers coalesced onto one request start on the same vertical line, a cache hit is a sliver, and a retry storm is a row of bars with the backoff visible between them. A request still running is drawn up to the moment the snapshot was read.
HARImporter is the other half of the export: it reads HAR 1.2 from any producer, and restores what
HAR has no field for — attempts, coalescing, cache outcome — when the file came from HARExporter.
await recorder.load(try HARImporter().import(Data(contentsOf: url)))Inspector is a macOS app built on those two lines: drop a HAR on it and read it with
the same panel a live app embeds.
The client already coordinates when to refresh. This supplies what to refresh with.
import NovaNetworkAuth
let oauth = OAuth2Client(configuration: OAuth2Configuration(
clientID: "your-client-id",
authorizationEndpoint: URL(string: "https://auth.example.com/authorize")!,
tokenEndpoint: URL(string: "https://auth.example.com/token")!,
redirectURI: URL(string: "yourapp://callback")!,
scopes: ["profile", "email"]
))
let authenticator = OAuth2Authenticator(client: oauth)
var configuration = NetworkClientConfiguration()
configuration.authRefreshProvider = authenticator.refreshProvider
configuration.middleware = [authenticator.middleware]let pkce = PKCEChallenge.generate()
let state = UUID().uuidString
let url = try oauth.authorizationURL(state: state, challenge: pkce)
// The app opens `url` and receives the callback.
let code = try oauth.authorizationCode(from: callbackURL, expectedState: state)
let token = try await oauth.exchange(code: code, verifier: pkce.verifier)
try await authenticator.setToken(token)PKCE is always used: the verifier is 32 random bytes as base64url, the challenge its SHA-256, so an
intercepted code is useless without the secret that never left the device. The state check runs
before anything else is read and throws without returning the code — that check is what stops an
attacker's authorization code being redeemed in your user's session.
An actor alone is not enough: it suspends at the network call, so a second caller arriving mid-refresh would start another. The authenticator shares the in-flight task, so eight simultaneous callers produce one request to the provider.
Two details that are wrong more often than they are right, and are handled here:
- a refresh response usually omits
refresh_token, meaning "keep the one you have" — dropping it is how a session dies an hour later for no visible reason; - a token with no
expires_inis never treated as expired, because guessing would refresh working credentials on a timer.
A refresh rejected with invalid_grant clears the stored token, so the app can ask for a sign-in
instead of failing identically forever.
let authorization = try await oauth.requestDeviceAuthorization()
print("Go to \(authorization.verificationURI) and enter \(authorization.userCode)")
let token = try await oauth.pollForToken(authorization) // honors slow_down and authorization_pendingTokens live in memory by default; KeychainTokenStore files them as generic passwords with
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, so they are reachable from a background refresh
and never sync to another device. For APIs that authenticate with a shared secret,
HMACRequestSigner signs over a documented canonical form and ships as middleware.
Not included: presenting a browser (it needs a window anchor the app owns) and AWS Signature Version 4 (its correctness needs reference vectors; an unverified signer would look finished and be dangerous). Both are stated rather than quietly missing.
Supabase's GoTrue reads grant_type from the query string and everything else from a JSON body.
Describe the shape and the standard grants keep working:
configuration.tokenRequestStyle = OAuth2TokenRequestStyle(
bodyEncoding: .json,
grantTypePlacement: .query,
additionalHeaders: ["apikey": anonKey]
)When the difference is bigger than an encoding, supply the exchange itself and keep the rest:
let authenticator = OAuth2Authenticator(
configuration: configuration,
exchange: OAuth2TokenExchange { grant in try await myTokenRequest(grant) },
store: KeychainTokenStore(service: "com.example.app", account: "session")
)The closure gets the grant_type, the parameters, the endpoint, and the token being refreshed;
storage, the single refresh across a burst of 401s, the retained refresh token, the invalid_grant
sign-out, and the middleware all stay library-side. A sign-in this package has no grant for —
grant_type=password among them — is performed by the app and handed over with setToken(_:).
A runnable walkthrough is in Examples/Authentication.
NetworkClient answers "perform this request". A screen asks something else: what is the current
state of this resource, and tell me when it changes.
import NovaNetworkQuery
let queries = QueryClient()
let user: User = try await queries.value(for: QueryKey("users", 1)) {
try await client.load(request: request, authScope: nil)
}| Without | With |
|---|---|
| Two screens fetch the same list twice | One entry per key, one in-flight fetch per key |
| Pull-to-refresh blanks the list to a spinner | The old value stays visible, marked stale, while the refresh runs |
| A delete leaves the row on screen | The mutation invalidates the key and every screen updates |
| A failed optimistic edit stays applied | The exact previous value is restored |
for await state in await queries.states(for: QueryKey("users", 1), as: User.self) {
switch state {
case .idle, .loading(nil): showSpinner()
case let .loading(.some(user)),
let .success(user, _): show(user, stale: state.isStale)
case let .failure(error, previous): show(error, keeping: previous)
}
}Subscribers receive the current state first, so a screen arriving late is never blank. Errors are
part of the state rather than only thrown, so a view can render the problem beside the value it
already had. On iOS 17+, ObservableQuery wraps the same stream for SwiftUI — the only
availability-gated piece, because the package's floor stays at iOS 13.
try await queries.mutate(
optimistic: [QueryKey("users", 1): editedUser],
invalidating: [QueryKey("users", 1), "users"]
) {
try await client.load(request: saveRequest, authScope: nil)
}A failure restores the exact captured snapshot, not a reversed diff — the only approach that
stays correct when two mutations race. Keys are hierarchical, so invalidate(prefix: "users") marks
the whole family stale; entries with subscribers refetch immediately, and entries nobody is watching
are marked and left alone rather than spending the user's battery.
let feed = PagedQuery<Post, String>(key: "feed", client: queries) { cursor in
let page: FeedPage = try await client.load(request: feedRequest(after: cursor), authScope: nil)
return QueryPage(elements: page.posts, nextCursor: page.next)
}
let posts = try await feed.loadNextPage() // everything so far, not just the new pageNot included: a normalized entity cache (values are stored by key, not merged into a graph) and persistence (the offline queue remains the durable path for writes).
A runnable demo is in Examples/Query.
import Foundation
import NovaNetworkClient
let preset = NetworkClientPreset.offlineFirst
let client = NetworkClient(
transport: Transport(),
retryPolicy: preset.retryPolicy,
defaultCachePolicy: preset.defaultCachePolicy,
offlineWriteStore: DiskOfflineWriteStore(directoryURL: queueURL)
)
await client.applyRuntimePolicy(from: preset)
let safeOptions = preset.requestOptions(
overrides: .init(
priority: .high,
rateLimitPolicy: RateLimitPolicy(maxRequests: 6, intervalSeconds: 1)
)
)
let payload = try await client.load(
request: request,
authScope: "user:42",
options: safeOptions
)import Foundation
import NovaNetworkClient
let profile = NetworkClientProductionProfileGenerator().generate(
goal: .offlineFirst,
overlays: [.offlineDurability, .strictReliability],
offlineStoreConfigured: true
)
guard profile.validation.isProductionReady else {
for issue in profile.validation.issues {
print("[\(issue.severity.rawValue)] \(issue.code): \(issue.message)")
}
fatalError("Fix production validation issues before rollout.")
}
let preset = profile.composedPreset
let client = NetworkClient(
transport: Transport(),
retryPolicy: preset.retryPolicy,
defaultCachePolicy: preset.defaultCachePolicy,
offlineWriteStore: DiskOfflineWriteStore(directoryURL: queueURL)
)
await client.applyRuntimePolicy(from: preset)struct UserProfile: Decodable, Sendable {
let id: Int
let name: String
}
let request = APIRequest(
method: .get,
url: URL(string: "https://api.example.com/profile")!,
queryItems: [URLQueryItem(name: "id", value: "42")]
)
let profile: UserProfile = try await client.load(
request: request,
authScope: "user:42"
)let createItem = APIRequest(
method: .post,
url: URL(string: "https://api.example.com/items")!,
body: Data("{\"name\":\"draft\"}".utf8)
)
.withIdempotencyKey("create-item-user-42")
let result = try await client.enqueueWrite(
request: createItem,
authScope: "user:42",
options: .init(offlineQueuePolicy: .init(mode: .enqueueWhenOffline))
)
switch result {
case .completed:
print("Request sent immediately")
case .queued(let receipt):
print("Saved for replay: \\(receipt.queueID)")
}let stream = client.events()
Task {
for await event in stream {
print("event:", event)
}
}let queuePolicy = OfflineQueuePolicy(
mode: .enqueueWhenOffline,
replayPriority: .critical,
replaySchedulerPolicy: .init(
fairReplayWeights: [.critical: 4, .normal: 2, .background: 1],
starvationProtectionAgeSeconds: 120,
priorityBandLimits: [
.init(priority: .critical, maxConsecutiveReplays: 8),
.init(priority: .normal, maxConsecutiveReplays: 4),
.init(priority: .background, maxConsecutiveReplays: 2)
],
replayWindow: .init(
maxContinuousReplaySeconds: 20,
coolDownSeconds: 1,
maxReplaysPerSecond: 10
)
),
replayConflictPolicy: .manualReview
)
let client = NetworkClient(
transport: Transport(),
offlineWriteStore: DiskOfflineWriteStore(directoryURL: queueURL),
offlineConflictResolver: { metadata in
if metadata.statusCode == 409 {
return .manualReview(reason: "server_conflict_requires_review")
}
return .retry(afterSeconds: nil)
}
)
_ = try await client.enqueueWrite(
request: createItem,
authScope: "user:42",
options: .init(offlineQueuePolicy: queuePolicy)
)let snapshot = await client.offlineQueueSnapshot()
if let manualItem = snapshot.first(where: { $0.state == .manualReview }) {
let requeued = await client.replayManualReviewItem(
queueID: manualItem.receipt.queueID,
resolutionReason: "resolved_in_ui"
)
print("Requeued:", requeued)
}
let metrics = await client.offlineQueuePipelineMetrics()
print(metrics.queueDepth)
print(metrics.ageDistribution.p90Seconds)
print(metrics.ageDistribution.p95Seconds)
print(metrics.replayThroughput.replaysPerSecond)
print(metrics.terminalOutcomes)let rotated = await client.rotateOfflineQueueEncryption()
print("Rewritten encrypted records:", rotated)let client = NetworkClient(defaultCachePolicy: .cacheFirst(maxAge: 5))
let data = try await client.load(
request: request,
authScope: "user:42",
cachePolicy: .staleWhileRevalidate(maxAge: 2, staleAge: 30)
)POST, PUT, PATCH, and DELETE never take part in the cache under any of these policies, no
matter what the server's Cache-Control says. A stored response to a write either swallows the write
or answers it with the reply to an earlier one, and both failures are silent — a sign-in that returns
an expired token reads as a wrong password.
Say so explicitly when a POST is genuinely a read:
let data = try await client.load(
request: searchRequest, // POST /search
authScope: "user:42",
cachePolicy: .includingUnsafeMethods(.cacheFirst(maxAge: 30))
)A request carrying Cache-Control: no-store still bypasses the cache, opt-in or not.
let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("NovaNetworkClientCache")
let client = NetworkClient(
transport: Transport(),
cache: DiskResponseCache(directoryURL: cacheURL),
defaultCachePolicy: .cacheFirst(maxAge: 30)
)let data = try await client.load(
request: request,
authScope: "user:42",
options: RequestExecutionOptions(
coalescerLimitsOverride: .init(maxInFlightKeys: 8),
priority: .high,
capacityScheduling: .queueByPriority,
coalescingMode: .custom("feed:primary"),
deadlineBudgetSeconds: 2.0,
circuitBreakerPolicy: CircuitBreakerPolicy(
scope: .host,
failureThreshold: 3,
cooldownSeconds: 10
)
)
)await client.updateRuntimePolicy(
.init(
retryPolicy: RetryPolicy(
maxAttempts: 3,
adaptiveProfiles: [
.rateLimited: .init(maxAttempts: 2, baseDelayNanoseconds: 1_000_000_000, jitterRange: nil)
]
)
),
scope: .host("api.example.com")
)
await client.updateRuntimePolicy(
.init(deadlineBudgetSeconds: 0.5),
scope: .endpoint(host: "api.example.com", pathPrefix: "/critical")
)
// Dedupe TTL control for coalescing windows (endpoint > host > global).
await client.updateRuntimePolicy(
.init(coalescingPolicy: .init(dedupeTTLSeconds: 30)),
scope: .host("api.example.com")
)
// Runtime circuit breaker tuning (threshold/cooldown/probe policy).
await client.updateCircuitBreakerRuntimePolicy(
.init(
scope: .host,
failureThreshold: 5,
cooldownSeconds: 8,
probePolicy: .parallelProbes(maxConcurrent: 2)
),
scope: .host("api.example.com")
)
// Runtime fairness scheduler tuning for queued coalescer capacity.
await client.updateCoalescerSchedulerPolicy(
.init(highWeight: 4, mediumWeight: 2, lowWeight: 1)
)let authMiddleware = NetworkMiddleware(
beforeSend: { request, _ in
request.withMergedHeaders(["Authorization": "Bearer token"])
}
)
let client = NetworkClient(
transport: Transport(),
middlewares: [authMiddleware]
)let data = try await client.load(
request: request,
authScope: "user:42",
options: .init(
rateLimitPolicy: .init(maxRequests: 5, intervalSeconds: 1)
)
)Before wiring this up, read Offline-First Apps: the queue stores requests, and an app whose unit of edit is a row in SwiftData or GRDB is usually better served by a dirty flag on that row. Running both over the same edit sends it twice.
let queueURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("NovaNetworkClientOfflineQueue")
let client = NetworkClient(
transport: Transport(),
offlineWriteStore: DiskOfflineWriteStore(
directoryURL: queueURL,
cipher: AESGCMOfflineWriteStoreCipher(
keyProvider: {
// Provide 16/24/32-byte key material from secure storage.
Data(repeating: 7, count: 32)
}
)
)
)
let writeRequest = APIRequest(
method: .post,
url: URL(string: "https://api.example.com/items")!,
body: Data("{\"name\":\"draft\"}".utf8)
)
let result = try await client.enqueueWrite(
request: writeRequest,
authScope: "user:42",
options: .init(
idempotencyPolicy: .init(keyStrategy: .fingerprintDigest),
offlineQueuePolicy: .init(
mode: .enqueueWhenOffline,
maxReplayAttempts: 5,
replayConflictPolicy: .manualReview,
replayDedupeWindowSeconds: 24 * 60 * 60
)
)
)
switch result {
case .completed(let body):
print("sent now:", body.count)
case .queued(let receipt):
print("queued:", receipt.queueID)
}
let replayed = await client.flushOfflineQueue()
print("replayed:", replayed)Replay behavior knobs:
maxReplayAttempts: terminal threshold before conflict handling path.replayConflictPolicy:.retry: keep entry in retry flow..drop: drop terminal conflict from queue..manualReview: keep entry with manual-review state.
replayDedupeWindowSeconds: suppress replay if same replay identity already succeeded in window.
Queue management helpers:
let depth = await client.offlineQueueDepth()
let snapshot = await client.offlineQueueSnapshot()
if let first = snapshot.first {
_ = await client.dropQueuedWrite(queueID: first.receipt.queueID)
}
_ = await client.dropAllQueuedWrites()Offline queue event stream:
for await event in client.offlineQueueEvents() {
print(event)
}for try await chunk in client.loadStream(request: request, authScope: "user:42") {
print("chunk bytes:", chunk.count)
}let events = client.loadServerSentEvents(
request: APIRequest(method: .get, url: URL(string: "https://api.example.com/stream")!),
authScope: "user:42"
)
for try await event in events {
print(event.event, event.data)
}By default the stream reconnects automatically after the connection ends or fails, resending a
Last-Event-ID header once the server has provided one and honoring any retry: field the
server sends. Pass reconnectPolicy: .disabled for a single-attempt stream, or tune
SSEReconnectPolicy(maxAttempts:defaultDelayNanoseconds:maxDelayNanoseconds:) for bounded
reconnection. Requires a transport that implements ServerSentEventTransport (the default
Transport does); other transports fail immediately with
ServerSentEventError.transportUnsupported.
The wire-format parser (SSEDecoder, SSELineParser) lives in NovaNetworkCore and has no
platform dependencies, so it is usable standalone (for example against a custom transport) on
every supported platform, including Linux.
let socket = WebSocketClient(
configuration: WebSocketConfiguration(
url: URL(string: "wss://ws.postman-echo.com/raw")!,
headers: ["Authorization": "Bearer token"],
outboundQueuePolicy: .init(maxQueuedMessages: 100, overflowPolicy: .dropOldest),
ackPolicy: .init(
dedupeWindowNanoseconds: 120_000_000_000,
maxTrackedMessageIDs: 2_048,
maxResendAttempts: 1
),
authRefreshPolicy: .init(maxAttempts: 1),
subscriptionReplayPolicy: .init(maxAttemptsPerSubscription: 2, retryDelayNanoseconds: 100_000_000)
),
authRefreshProvider: .init(
refreshHeaders: {
["Authorization": "Bearer refreshed-token"]
}
)
)
let stateStream = await socket.connectionStates()
Task {
for await state in stateStream {
print("ws state:", state)
}
}
let messageStream = await socket.messages()
Task {
for try await message in messageStream {
print("ws message:", message)
}
}
try await socket.connect()
try await socket.send(
.text("{\"type\":\"ping\"}"),
options: .init(requiresAck: true, messageID: "ping-1", ackTimeoutNanoseconds: 5_000_000_000)
)
await socket.registerSubscription(
id: "orders-stream",
message: .text("{\"type\":\"subscribe\",\"channel\":\"orders\"}")
)
let health = await socket.connectionHealth()
print("ws health:", health)
let diagnostics = await socket.webSocketDiagnostics()
print("ws queue depth:", diagnostics.queuedOutboundMessages)
print("ws queue pressure:", diagnostics.queuePressureLevel)
print("ws ack age buckets:", diagnostics.ackPendingAgeBuckets)
print("ws reconnect phase:", diagnostics.reconnectPhase, diagnostics.lastTransitionReason ?? "n/a")
await socket.forceReconnect(reason: "manual_recovery")
await socket.disconnect(reason: "done")You can override ACK parsing for custom backend protocols:
let socket = WebSocketClient(
configuration: .init(url: URL(string: "wss://example.com/ws")!),
ackMatcher: .init { message in
guard case .text(let value) = message, value.hasPrefix("ACK|") else { return nil }
return String(value.dropFirst(4))
}
)For connectivity-aware reconnect suppression/resume, pass connectivityMonitor (OfflineConnectivityMonitor) when creating WebSocketClient.
For durable outbound buffering across app restarts, pass outboundQueueStore (for example DiskWebSocketOutboundQueueStore).
For optional subscription restore after reconnect, register subscriptions via registerSubscription(id:message:options:).
A re-sent subscription starts from "now", so whatever the server published while the socket was down
is lost. Pass an eventResume policy and the client keeps the last sequence it delivered per stream,
re-sends each registered subscription asking for what came after it, drops the overlap, and reports
what it could not recover:
let socket = WebSocketClient(
configuration: .init(url: URL(string: "wss://example.com/ws")!),
eventResume: .default
)
// Events look like {"stream":"orders","seq":42,...}. The stream ID is the subscription ID.
await socket.registerSubscription(id: "orders", message: .text(#"{"type":"subscribe","channel":"orders"}"#))
// After a reconnect the client sends {"afterSeq":42,"channel":"orders","type":"subscribe"}.
Task {
for await event in await socket.eventResumeEvents() {
switch event {
case .resyncRequired(let stream), .gapDetected(let stream, _, _):
let snapshot = try await api.orders() // refetch over HTTP
await socket.seedEventCursor(snapshot.seq, forStream: stream)
case .resumeRequested:
break
}
}
}.default reads "stream" and "seq", resumes a JSON-object subscription by adding "afterSeq",
and treats {"type":"resync_required","stream":"…"} as "history no longer reaches back". A different
protocol is three closures: WebSocketEventResumePolicy(position:resumeMessage:resyncSignal:).
Events at or below the cursor never reach messages(). To resume after a relaunch, persist
eventCursor(forStream:) and hand it back with seedEventCursor(_:forStream:). Telemetry:
event_resume_requested, event_gap_detected, event_duplicate_dropped, event_resync_required.
WebSocketDiagnostics also includes queue pressure, ACK pending age buckets, and reconnect phase/reason snapshots for incident triage.
let inFlight = await client.inFlightRequests()
print(inFlight.map(\.key))A share extension is a separate process with its own container: a write queued there is invisible to the app. The only directory both can see is the App Group container — and the moment they both use it, the actor protecting the queue stops being enough, because an actor serialises one process and these are two.
let directory = try AppGroupContainer.directory(
forAppGroup: "group.com.example.app",
subdirectory: "offline-queue"
)
configuration.offlineWriteStore = CoordinatedOfflineWriteStore(
wrapping: DiskOfflineWriteStore(directoryURL: directory),
lock: CrossProcessFileLock(url: directory.appendingPathComponent(".lock"))
)CrossProcessFileLock is flock on a lock file, taken without blocking and retried — the
blocking form would park the cooperative thread it runs on and stall every other request in the
process while a different process worked. It is a value type rather than an actor because it holds
no mutable state: the mutual exclusion lives in the filesystem.
CoordinatedOfflineWriteStore is a decorator, so the existing store keeps behaving exactly as it did
and anything conforming to OfflineWriteStore can be wrapped the same way.
Two things worth knowing before adopting. The lock is advisory: a process that writes the directory without taking it is not stopped. And the platforms disagree about a missing entitlement — iOS reports it, macOS hands back a container path regardless, so there the mistake only surfaces when something tries to write. See What's New 3.4.
Reachability answers one bit: can we reach anything. That bit cannot tell a metered hotspot from home Wi-Fi, so it cannot decide whether to start a 40 MB upload — and a phone with Low Data Mode on is still "connected".
var configuration = NetworkClientConfiguration()
configuration.networkPathMonitor = SystemNetworkPathMonitor() // reads Network.framework
configuration.networkPathPolicy = .respectMeteredPathsOff by default: with no policy the client never looks at the path.
Three answers rather than two. send goes now, fail does not go and is not kept, and
deferUntilPathImproves does not go now but is kept — reaching the existing offline queue,
because a deferral is reported as the URLError that queue already recognises. A failure carries
NetworkPathRestrictionError, which it does not, so it propagates.
try await client.load(request: signIn, authScope: nil, options: .init(isEssential: true))Essential requests pass a metered or constrained path — a policy that also blocked the sign-in would
be a policy nobody could adopt. And Low Data Mode outranks the cost guess: isConstrained is the
user saying "use less data here", isExpensive is an inference about the link.
SystemNetworkPathMonitor translates NWPath and decides nothing, so policies stay testable on a
machine with no cellular interface:
configuration.networkPathMonitor = StaticNetworkPathMonitor(
NetworkPath(status: .satisfied, interfaces: [.cellular], isExpensive: true)
)The policy is consulted once, when the request starts; see What's New 3.3 for the rest of the edges.
The package already decides whether to send a request — coalescing, caching, retry, circuit breaking, rate limiting. This decides how many at once, from what the server is actually doing:
var configuration = NetworkClientConfiguration()
configuration.adaptiveConcurrency = AdaptiveConcurrencyPolicy(
minimumLimit: 1,
maximumLimit: 16,
initialLimit: 6
)
let client = NetworkClient(configuration: configuration)That is the whole installation, and it is off by default: with no policy the client admits requests exactly as it always has.
Additive up, multiplicative down. The limit grows by one and shrinks by a factor, never the other way round — backing off slowly from an overloaded server is how a client turns a slowdown into an outage. It grows only when a request actually waited for a slot and then succeeded; raising it while slots sit idle would let it drift to the maximum without evidence.
Slower counts before refused does. A server gets slower before it starts refusing, so each response is compared against the fastest one seen:
AdaptiveConcurrencyPolicy(latencyDegradationFactor: 2.0) // twice the best seen is congestion
AdaptiveConcurrencyPolicy(latencyDegradationFactor: .infinity) // react only to refusalsOnly failures that mean "too much at once" count — 429, 503, timeouts, connection loss. A 404 is a fact about a URL and a cancellation is a fact about the caller; neither shrinks the limit.
It queues rather than refuses. RateLimitPolicy says "too many, come back later"; this says
"not yet, you are next". Callers wait in arrival order, a cancelled one leaves the queue intact, and
queueTimeoutSeconds lets a caller give up. Coalesced callers share one slot, because the limiter
sits inside the coalescer and bounds requests that reach the transport rather than callers.
configuration.telemetryHooks = NetworkTelemetryHooks(
onConcurrencyLimitChanged: { print("limit \($0.previousLimit) → \($0.limit): \($0.reason)") }
)
let snapshot = await client.concurrencySnapshot() // limit, in flight, waiting, best latencyOne limiter per client, not per host; a retrying request holds its slot while it waits out backoff;
and every launch starts at initialLimit. Those are the known edges, listed in
What's New 3.2.
swift run NovaNetworkClientBenchmarks --check-baselineswift run NovaNetworkClientBenchmarks --stress-suite
swift run NovaNetworkClientBenchmarks --check-stress-baselineThe stress baseline includes a combined offline-replay + realtime request pressure scenario.
Both checks run in CI, and they draw a line through the numbers they print:
- Enforced — everything the code decides and that holds on any machine: how many calls the transport saw, retry-storm outcomes, breaker transitions, replay counts. A change to coalescing, retry, or caching moves these, and moving them fails the build.
- Advisory — elapsed time, p95/p99 latency, and allocation deltas. On a shared CI runner these
are as much a property of the machine as of the code, so they are printed and reported as
baseline_check=passed_with_advisoryrather than failing. A budget that fails because a neighbouring job was busy teaches a team to ignore the gate.
Pass --strict-timing to enforce the timing budgets too, which is what you want on dedicated
hardware:
swift run NovaNetworkClientBenchmarks --check-baseline --strict-timingAsking for a check and not being able to perform it is a failure: if Benchmarks/baseline.json
cannot be read — which is what happens when the benchmark is run from outside the repository root —
the check exits non-zero rather than reporting success without having compared anything.
swift test --filter OfflineQueueCoverageTests
swift test --filter connectivityFlapSequenceQueuesOfflineWritesAndRecoversOnFlushOfflineStoreRecoveryReport includes orphanedTemporaryRecords, corruptionBudgetExceeded, and recoveryLossRate for provable recovery-loss accounting.
let result = client.uploadMultipart(
url: URL(string: "https://api.example.com/items")!,
parts: [
.text(name: "title", value: "My upload"),
.file(name: "photo", filename: "photo.jpg", contentType: "image/jpeg", fileURL: photoURL),
],
authScope: "user:42"
)
for try await event in result {
switch event {
case .progress(let progress):
print("uploaded:", progress.completedBytes)
case .completed(let response):
print("status:", response.statusCode)
}
}.file parts are streamed from disk in fixed-size chunks and are never fully buffered in
memory, regardless of file size. Content-Type defaults to multipart/form-data; boundary=... unless already supplied in headers.
uploadMultipart is a convenience over MultipartFormDataEncoder, which can also be used
directly: write(to:) streams the encoded body to any file you choose, and contentType
returns the matching header value. That decouples encoding from transport, so the same encoded
file can be handed to any file-based upload path, including a durable/resumable one, instead of
uploadMultipart's own upload-then-delete-temp-file flow.
APIRequest.method has type URLMethod; HTTPMethod is a typealias for the same enum, so both
spellings compile and either one finds it in the documentation.
URLMethod.post.isSafe // false
URLMethod.get.isCacheableByDefault // true — only GET and HEAD areThose two properties are what the client consults before a request may read from or write to the response cache.
let request = try APIRequest
.builder(method: .post, url: URL(string: "https://example.com/api")!)
.header("Accept", "application/json")
.jsonBody(["name": "Max"])
.build()struct User: Decodable, Sendable {
let id: Int
let name: String
}
let user: User = try await client.load(
request: request,
authScope: "user:42"
)load<T: Decodable> and Endpoint.decode(_:using:) always use a fixed JSONDecoder — the
existing default, unchanged. For a decoder that needs to see response headers (most commonly to
pick a strategy by Content-Type), use ResponseDecoding instead:
let decoding = ContentTypeNegotiatingResponseDecoding(
decodersByMediaType: [
"application/xml": MyXMLResponseDecoding()
]
// falls back to JSONResponseDecoding() for any other Content-Type
)
let user: User = try await client.decode(
request: request,
authScope: "user:42",
responseDecoding: decoding
)Set configuration.responseDecoding on a NetworkClientConfiguration for a client-wide default
strategy, used whenever a call to decode(request:...) does not supply its own. decode and its
companion loadResponse(request:authScope:options:) (which returns the full response — status,
headers, and body — instead of just Data) always bypass the response cache, matching
cachePolicy: .networkOnly.
By default, fingerprint includes method, canonical URL + query, canonical JSON body digest, headers digest, and auth scope digest.
let policy = FingerprintPolicy(
includeQueryItems: true,
includeBody: true,
headerInclusion: .allowlist(["Accept", "Content-Type"])
)
let client = NetworkClient(
transport: Transport(),
fingerprintPolicy: policy
)let policy = CertificatePinningPolicy(
pinsByHost: [
"api.example.com": [
"4UgYzKuqOyIb7kHr8BLRmzF7nVf1ZLld+MPOVlPZSxA=", // primary
"X9ow7ZHXqNUe817kCZu4lYmsTgZz4xM5956G5xkLbcw=", // backup, for rotation
]
],
unpinnedHostPolicy: .allowDefaultEvaluation
)
let transport = Transport.pinned(
policy: policy,
onValidation: { event in
print("pinning:", event.host, event.outcome)
}
)
let client = NetworkClient(transport: transport)Pins are base64 SPKI SHA-256 digests, matching:
openssl x509 -in cert.pem -pubkey -noout \
| openssl pkey -pubin -outform DER \
| openssl dgst -sha256 -binary \
| openssl base64Configuring more than one pin per host allows certificate/key rotation without an app update:
a connection succeeds as soon as any certificate in the chain matches any configured pin. Hosts
without configured pins follow unpinnedHostPolicy (allowDefaultEvaluation or reject); the
platform's own trust evaluation still runs first for pinned hosts, so a connection must satisfy
both the system trust store and the pin.
For mutual TLS, supply a ClientCertificateProvider:
let transport = Transport.pinned(
policy: policy,
clientCertificateProvider: .fixed(
ClientCertificateIdentity(identity: mySecIdentity)
)
)This functionality depends on the Security framework and is available on Apple platforms only.
| Scenario | Behavior |
|---|---|
| Same key, concurrent callers | One underlying operation executes; all waiters share result |
| Same key, next request after completion | New operation starts |
cancelWhenNoWaiters, one waiter cancels |
Operation continues while at least one waiter remains |
cancelWhenNoWaiters, all waiters cancel |
Underlying task is cancelled |
keepRunning, all waiters cancel |
Underlying task continues until completion |
| Retriable error + attempts remaining | Retries with exponential backoff (optional jitter) |
| Non-idempotent request without idempotency key | No retry by default (can be overridden in RetryPolicy) |
| Non-retriable error | Fails immediately |
| Deadline budget exhausted | Request fails with timeout budget error and no additional retry attempt |
coalescingMode: .disabled |
Identical concurrent requests execute independently |
NetworkError conforms to Equatable and LocalizedError:
catch let error as NetworkError {
print(error.localizedDescription) // "The server returned HTTP status 429."
#expect(error == .clientRateLimited(retryAfterSeconds: 30)) // usable directly in test assertions
}Equatable cases wrapping an underlying error (.decoding, .transport,
.authenticationRefreshFailed) compare that error by dynamic type and description, since any Error has no general equality contract; treat this as a best-effort comparison for tests and
deduplication, not a guarantee of true value identity.
For request and attempt context (URL, method, attempt number, auth scope) without changing
NetworkError's own cases, wrap it with NetworkErrorContext — directly, or automatically via
loadWithContext:
do {
let data = try await client.loadWithContext(request: request, authScope: "user:42")
} catch let error as ContextualNetworkError {
logger.error("\(error.localizedDescription)") // includes the request's method, URL, attempt
reportToCrashlytics(error.error, context: error.context)
}Use observer events, telemetry hooks, or the OpenTelemetry adapter layer:
let exporter = MyOpenTelemetryExporter() // implements OpenTelemetryExporting
let adapter = OpenTelemetryAdapter()
let client = NetworkClient(
transport: Transport(),
telemetryHooks: adapter.makeHooks(exporter: exporter)
)
let pipeline = await client.offlineQueuePipelineMetrics()
adapter.emitPipelineMetrics(
pipeline,
exporter: exporter,
attributes: ["scope": .string("global")]
)Observability Contract v2 payloads include top-level event_version and contract_version.
Default values are 2 and "2.0".
let responses = try await client.loadBatch(
requests: [requestA, requestB, requestC],
authScope: "user:42"
)NovaNetworkClientTestSupport is a separate product for testing code that uses
NovaNetworkClient, without a real server.
import NovaNetworkClientTestSupport
let router = RoutingTransport()
await router.register(.method(.get) && .pathPrefix("/users/"), statusCode: 200, body: userJSON)
await router.register(.method(.post) && .path("/users")) { request in
NetworkResponse(statusCode: 201, headers: [:], body: request.body ?? Data())
}
let client = NetworkClient(transport: router)RoutingTransport dispatches each request to the first registered route whose RequestMatcher
matches (.method, .path, .pathPrefix, .host, .header, .bodyContains, .url, combined
with &&/||); an unmatched request throws RoutingTransportError naming the method and URL,
rather than hanging or returning a misleading default.
For resilience testing, wrap any transport with randomized failures and latency:
let chaos = ChaosTransport(
wrapping: router,
policy: ChaosPolicy(failureRate: 0.3, delayRange: 0...200_000_000),
randomGenerator: TestRetryRandom(value: 0.5) // deterministic for reproducible runs
)For deterministic timing assertions, VirtualClock (a RetryClock) genuinely suspends callers
until you explicitly advance virtual time:
let clock = VirtualClock()
let client = NetworkClient(transport: transport, retryPolicy: policy, retryClock: clock)
let task = Task { try await client.load(request: request, authScope: nil) }
await clock.advanceToNextDeadline() // releases the first retry's backoff sleepTelemetryRecorder records every event a client's NetworkTelemetryHooks emits, for asserting
on retries, coalescing, cancellations, and other lifecycle events instead of re-deriving them
from side effects:
let recorder = TelemetryRecorder()
let client = NetworkClient(transport: transport, telemetryHooks: await recorder.makeHooks())
_ = try await client.load(request: request, authScope: nil)
await waitUntil { await recorder.requestEndCount() == 1 } // hooks record asynchronouslyMockTransport and ScriptedTransport (a single fixed response, and an ordered sequence of
responses, regardless of request content) remain available for simpler cases that don't need
routing.
swift testE2E tests use public APIs (jsonplaceholder.typicode.com, httpbin.org) and are disabled by default.
E2E suite policy: only real public APIs are allowed; mocks/stubs are not allowed in E2E tests.
Scenarios include typed load, coalescing, batch loading, cache hit path, middleware, stream fallback, offline queue enqueue/flush/drop, rate limit, error mapping, runtime policy updates, circuit breaker, and invalidation paths.
RUN_E2E_TESTS=1 swift test --filter E2ECoverageTestsNovaNetworkCore has a Linux CI guarantee (Ubuntu, Swift 6.2.3, strict concurrency). Full
NovaNetworkClient Linux support is a work in progress: it has never run on Linux before this
was added, so this section describes what has been audited and gated, not what has been
proven working — the CI job below is the actual verification going forward.
- Apple-only APIs are behind explicit availability gates: certificate pinning and mTLS
(
Security,#if canImport(Security)) and the offline queue's optional AES-GCM encryption cipher (CryptoKit,#if canImport(CryptoKit)) simply aren't available where those frameworks aren't; everything else inNovaNetworkClient— coalescing, retry, circuit breaker, rate limiting, caching, the offline queue itself (without that one optional cipher), batching, fingerprinting, WebSocket — does not depend on either. - Request/cache-key fingerprinting no longer requires
CryptoKit:SHA256Utiluses a from-scratch, dependency-free SHA-256 implementation (verified against the NIST SHA-256 test vectors, including a streaming variant fed non-block-aligned chunks) whenCryptoKitisn't available, so this core, non-optional behavior isn't gated behind an Apple-only framework. Encryption (as opposed to fingerprinting) remainsCryptoKit-only rather than a hand-rolled authenticated cipher — the security cost of getting that wrong is not a risk worth taking. - A new CI job (
linux-client-build-gate) compilesNovaNetworkClientandNovaNetworkClientTestSupporton Ubuntu with Swift 6.2.3. It's compile-only for now — running the test suite on Linux is a separate, unverified step.
This project is licensed under the Apache License, Version 2.0. See LICENSE for details.