Build a Production-Ready Networking Layer: Generic APIClient with Retry, Caching, and Auth
Every non-trivial iOS app reaches the same inflection point: scattered URLSession calls duplicated across a dozen
files, authentication logic copy-pasted into each view model, and a retry mechanism that only lives in the one screen
where a bug was reported. The networking layer is the first piece of infrastructure that shows its seams.
In this tutorial, you’ll build NetworkingKit — a reusable Swift Package that encapsulates a generic APIClient,
automatic token refresh, retry with exponential backoff, response caching, structured logging, and a full unit test
suite. Along the way, you’ll learn how to model API endpoints as first-class Swift types, compose behavior with the
interceptor pattern, and test network code without making a single real HTTP request.
Prerequisites
- Xcode 16+ with an iOS 18 deployment target
- Familiarity with networking layer architecture
- Familiarity with advanced generics
- Familiarity with unit testing in Swift
Contents
- Getting Started
- Step 1: Defining the APIEndpoint Protocol
- Step 2: Building the Core APIClient
- Step 3: Adding the Interceptor Pipeline
- Step 4: Token Refresh on 401 with an Actor
- Step 5: Retry with Exponential Backoff
- Step 6: Response Caching
- Step 7: Structured Request Logging
- Step 8: Unit Tests with MockURLProtocol
- Where to Go From Here?
Getting Started
Rather than shipping this code inside an app target, you’ll package it as a local Swift Package. This keeps networking concerns isolated from the rest of the app and makes the module easy to share across targets or extract into a separate repository later.
Create the package:
- Open Xcode and choose File → New → Package.
- Name the package
NetworkingKitand save it anywhere convenient (aPackages/subdirectory inside your workspace is a clean convention). - In the generated
Package.swift, configure one library product and two targets:
Open NetworkingKit/Package.swift and replace its contents with:
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "NetworkingKit",
platforms: [.iOS(.v18), .macOS(.v15)],
products: [
.library(name: "NetworkingKit", targets: ["NetworkingKit"]),
],
targets: [
.target(
name: "NetworkingKit",
path: "Sources/NetworkingKit"
),
.testTarget(
name: "NetworkingKitTests",
dependencies: ["NetworkingKit"],
path: "Tests/NetworkingKitTests"
),
]
)
The package has no external dependencies — everything you need ships inside the Apple SDKs.
Create the source directories:
mkdir -p NetworkingKit/Sources/NetworkingKit
mkdir -p NetworkingKit/Tests/NetworkingKitTests
You’ll create files inside these directories throughout the tutorial. The finished file tree will look like this:
Sources/NetworkingKit/
APIEndpoint.swift
APIClient.swift
Interceptors/
RequestInterceptor.swift
AuthenticationInterceptor.swift
CachingInterceptor.swift
LoggingInterceptor.swift
TokenRefresher.swift
RetryPolicy.swift
NetworkError.swift
PixarAPIEndpoint.swift
Tests/NetworkingKitTests/
MockURLProtocol.swift
APIClientTests.swift
RetryTests.swift
CachingTests.swift
With the package structure in place, it’s time to define the first and most important abstraction: the endpoint.
Step 1: Defining the APIEndpoint Protocol
The most common networking anti-pattern is constructing URLRequest values inline at the call site — a URL string
concatenated here, query items appended there, and headers sprinkled wherever the developer remembered to add them.
Modeling each API endpoint as a dedicated Swift type eliminates that inconsistency.
Create Sources/NetworkingKit/APIEndpoint.swift:
import Foundation
// Represents a valid HTTP method for a request.
public enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case patch = "PATCH"
case delete = "DELETE"
}
// A type that fully describes a single API endpoint.
// Conforming types provide everything needed to construct a URLRequest.
public protocol APIEndpoint {
var baseURL: URL { get }
var path: String { get }
var method: HTTPMethod { get }
var headers: [String: String] { get }
var queryItems: [URLQueryItem]? { get }
var body: (any Encodable)? { get }
}
The protocol is intentionally narrow. It answers one question: “What information does a single endpoint need to provide
so that a generic client can construct a valid URLRequest?” Nothing more.
Next, add a URLRequest builder so that the conversion logic lives in one place and never leaks into the client:
public extension APIEndpoint {
// Default headers: accept JSON. Individual endpoints can add or override.
var headers: [String: String] {
["Accept": "application/json"]
}
var queryItems: [URLQueryItem]? { nil }
var body: (any Encodable)? { nil }
// Constructs a URLRequest from the endpoint's properties.
// Throws if the URL components cannot be assembled.
func buildRequest() throws -> URLRequest {
var components = URLComponents(
url: baseURL.appendingPathComponent(path),
resolvingAgainstBaseURL: true
)
components?.queryItems = queryItems
guard let url = components?.url else {
throw NetworkError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
if let body {
request.httpBody = try JSONEncoder().encode(body)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
return request
}
}
Now define the concrete endpoints for the imaginary Pixar API. Create Sources/NetworkingKit/PixarAPIEndpoint.swift:
import Foundation
// The base URL for the PixarAPI. In a real project this would
// come from a build configuration or environment file.
private let pixarAPIBase = URL(string: "https://api.pixarapi.example.com/v1")!
// Models every endpoint exposed by the PixarAPI.
public enum PixarAPIEndpoint: APIEndpoint {
case films
case filmDetail(id: String)
case characters(filmID: String)
case search(query: String)
public var baseURL: URL { pixarAPIBase }
public var path: String {
switch self {
case .films: return "/films"
case .filmDetail(let id): return "/films/\(id)"
case .characters(let filmID): return "/films/\(filmID)/characters"
case .search: return "/search"
}
}
public var method: HTTPMethod {
switch self {
case .films, .filmDetail, .characters, .search: return .get
}
}
public var queryItems: [URLQueryItem]? {
switch self {
case .search(let query):
return [URLQueryItem(name: "q", value: query)]
default:
return nil
}
}
}
Notice how the search case encodes its query parameter through queryItems rather than building URL strings manually.
Adding a new endpoint is now a compiler-checked operation — forget to handle a path case and the switch is exhaustive.
Create Sources/NetworkingKit/NetworkError.swift for the error types referenced above:
import Foundation
// Typed errors that describe every failure mode in NetworkingKit.
public enum NetworkError: Error, LocalizedError {
case invalidURL
case httpError(statusCode: Int)
case decodingError(underlying: Error)
case noData
case tokenRefreshFailed
case cancelled
public var errorDescription: String? {
switch self {
case .invalidURL:
return "The endpoint produced an invalid URL."
case .httpError(let code):
return "The server returned HTTP \(code)."
case .decodingError(let underlying):
return "Response decoding failed: \(underlying.localizedDescription)"
case .noData:
return "The server returned an empty response body."
case .tokenRefreshFailed:
return "Token refresh failed. Please sign in again."
case .cancelled:
return "The request was cancelled."
}
}
}
Step 2: Building the Core APIClient
With endpoints modeled, you need a client that can execute them. The client itself will be defined by a protocol so that consumers can depend on an abstraction rather than a concrete type — a requirement for testability.
Create Sources/NetworkingKit/APIClient.swift:
import Foundation
// The primary interface for making network requests.
// Generic over the response type so callers state exactly what they expect back.
public protocol APIClientProtocol: Sendable {
func request<T: Decodable & Sendable>(
_ endpoint: any APIEndpoint
) async throws -> T
}
The Sendable constraint on the return type is a Swift 6 requirement: since request is async, the value crosses
concurrency boundaries, so it must be safe to share.
Now implement the live version:
public struct LiveAPIClient: APIClientProtocol {
private let session: URLSession
private let decoder: JSONDecoder
private let interceptors: [any RequestInterceptor]
public init(
session: URLSession = .shared,
interceptors: [any RequestInterceptor] = []
) {
self.session = session
self.interceptors = interceptors
let decoder = JSONDecoder()
// PixarAPI returns snake_case keys; map them to camelCase automatically.
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
self.decoder = decoder
}
public func request<T: Decodable & Sendable>(
_ endpoint: any APIEndpoint
) async throws -> T {
// Build the base URLRequest from the endpoint.
var urlRequest = try endpoint.buildRequest()
// Run the request through every interceptor in order.
for interceptor in interceptors {
urlRequest = try await interceptor.intercept(urlRequest)
}
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.noData
}
guard (200..<300).contains(httpResponse.statusCode) else {
throw NetworkError.httpError(statusCode: httpResponse.statusCode)
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw NetworkError.decodingError(underlying: error)
}
}
}
The interceptors array is where all the interesting behavior will live — authentication, caching, and logging each
become independent, composable objects rather than branches inside a monolithic method.
Add some response models to decode against. Create Sources/NetworkingKit/Models.swift:
import Foundation
public struct PixarFilm: Decodable, Sendable, Identifiable {
public let id: String
public let title: String
public let releaseYear: Int
public let studio: String
public let synopsis: String
}
public struct PixarCharacter: Decodable, Sendable, Identifiable {
public let id: String
public let name: String
public let filmId: String
public let voiceActor: String
}
public struct SearchResults: Decodable, Sendable {
public let films: [PixarFilm]
public let characters: [PixarCharacter]
}
Checkpoint: At this point the package compiles cleanly. Create a simple
main.swiftin a scratch Playground and callPixarAPIEndpoint.films.buildRequest()— you should get a validURLRequestpointing athttps://api.pixarapi.example.com/v1/filmswithGETas the method andAccept: application/jsonin its headers.
Step 3: Adding the Interceptor Pipeline
The interceptor pattern (sometimes called the middleware pattern) is a classic way to compose cross-cutting concerns
without coupling them to the core request logic. Each interceptor receives a URLRequest, optionally transforms it, and
returns the modified request for the next interceptor in the chain.
Create Sources/NetworkingKit/Interceptors/RequestInterceptor.swift:
import Foundation
// A type that can inspect and transform a URLRequest before it is sent.
// Interceptors are composed into a pipeline inside LiveAPIClient.
public protocol RequestInterceptor: Sendable {
func intercept(_ request: URLRequest) async throws -> URLRequest
}
The Sendable conformance is required because interceptors are stored in LiveAPIClient (a Sendable struct) and
called from async context.
Now create the authentication interceptor. Create Sources/NetworkingKit/Interceptors/AuthenticationInterceptor.swift:
import Foundation
// An abstraction over the token store so we can inject a mock in tests.
// Declared at file scope because Swift does not allow nested protocols.
public protocol TokenProvider: Sendable {
func validToken() async throws -> String
}
// Attaches a Bearer token to every outgoing request.
// Delegates token storage and retrieval to a TokenProvider.
public struct AuthenticationInterceptor: RequestInterceptor {
private let tokenProvider: any TokenProvider
public init(tokenProvider: any TokenProvider) {
self.tokenProvider = tokenProvider
}
public func intercept(_ request: URLRequest) async throws -> URLRequest {
let token = try await tokenProvider.validToken()
var modified = request
modified.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
return modified
}
}
Compose interceptors when creating the client. From the app side, this looks like:
// Simplified for clarity — token storage details omitted.
let client = LiveAPIClient(
interceptors: [
AuthenticationInterceptor(tokenProvider: KeychainTokenProvider()),
LoggingInterceptor(), // Added in Step 7
CachingInterceptor(), // Added in Step 6
]
)
The order matters: authentication runs first so that logging sees the final headers (minus sensitive values), and caching runs last so it intercepts the response on the way back.
Tip: Because
RequestInterceptoris a protocol, you can easily reorder or swap interceptors for different build configurations. A debug build might inject a verbose logging interceptor; a release build might use a minimal one.
Step 4: Token Refresh on 401 with an Actor
A Bearer token eventually expires. When the server responds with HTTP 401 Unauthorized, the client needs to silently
refresh the token and retry the original request. The tricky part: if three requests fire concurrently and all receive a
401, you want exactly one token refresh — not three racing refreshes that could invalidate each other.
Swift actors provide exactly the right tool for this. An actor serializes access to its mutable state, so the “is a refresh already in progress?” check becomes a compile-time safety guarantee rather than a lock-based runtime one.
Create Sources/NetworkingKit/TokenRefresher.swift:
import Foundation
// Manages token refresh with safe concurrent access.
// Only one refresh runs at a time, even when multiple requests get a 401.
public actor TokenRefresher {
// Injected refresh function — call the auth endpoint to exchange a
// refresh token for a new access token.
private let performRefresh: @Sendable () async throws -> String
// If a refresh is already running, subsequent callers receive the
// same continuation rather than kicking off a duplicate request.
private var refreshTask: Task<String, Error>?
public init(performRefresh: @Sendable @escaping () async throws -> String) {
self.performRefresh = performRefresh
}
// Returns a fresh, valid access token.
// Coalesces concurrent callers into a single refresh attempt.
public func refreshIfNeeded() async throws -> String {
if let existing = refreshTask {
// A refresh is already in flight — wait for it.
return try await existing.value
}
let task = Task { try await performRefresh() }
refreshTask = task
do {
let token = try await task.value
refreshTask = nil
return token
} catch {
// Clear the task so the next call triggers a fresh attempt.
refreshTask = nil
throw NetworkError.tokenRefreshFailed
}
}
}
Now update AuthenticationInterceptor to use TokenRefresher and to handle 401 responses by refreshing and retrying.
Because the interceptor pattern as defined in Step 3 only transforms requests (not responses), the retry logic belongs
in LiveAPIClient. Replace the request method in APIClient.swift:
public func request<T: Decodable & Sendable>(
_ endpoint: any APIEndpoint
) async throws -> T {
var urlRequest = try endpoint.buildRequest()
for interceptor in interceptors {
urlRequest = try await interceptor.intercept(urlRequest)
}
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.noData
}
// On 401, ask the token refresher to get a fresh token and retry once.
if httpResponse.statusCode == 401, let refresher = tokenRefresher {
_ = try await refresher.refreshIfNeeded()
// Re-run the interceptor pipeline so the new token is attached.
var retryRequest = try endpoint.buildRequest()
for interceptor in interceptors {
retryRequest = try await interceptor.intercept(retryRequest)
}
let (retryData, retryResponse) = try await session.data(for: retryRequest)
guard let retryHTTP = retryResponse as? HTTPURLResponse else {
throw NetworkError.noData
}
// If still 401 after refresh, the refresh itself failed or the
// credentials are invalid — surface the error and force a logout.
guard (200..<300).contains(retryHTTP.statusCode) else {
throw NetworkError.httpError(statusCode: retryHTTP.statusCode)
}
return try decoder.decode(T.self, from: retryData)
}
guard (200..<300).contains(httpResponse.statusCode) else {
throw NetworkError.httpError(statusCode: httpResponse.statusCode)
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw NetworkError.decodingError(underlying: error)
}
}
Add the tokenRefresher property to LiveAPIClient:
public struct LiveAPIClient: APIClientProtocol {
private let session: URLSession
private let decoder: JSONDecoder
private let interceptors: [any RequestInterceptor]
private let tokenRefresher: TokenRefresher? // ← New property
public init(
session: URLSession = .shared,
interceptors: [any RequestInterceptor] = [],
tokenRefresher: TokenRefresher? = nil // ← New parameter
) {
self.session = session
self.interceptors = interceptors
self.tokenRefresher = tokenRefresher
// ... decoder setup unchanged
}
}
Checkpoint: To verify this works, configure
MockURLProtocol(built in Step 8) to return a401on the first request and a200with valid JSON on the second. Therequest()call should complete successfully with a decoded value, andTokenRefresher.refreshIfNeeded()should have been called exactly once even when three concurrent tasks fire simultaneously.
Step 5: Retry with Exponential Backoff
Token refresh handles authentication failures. Retry with backoff handles transient infrastructure failures — network timeouts, connection resets, temporary server unavailability. These are different failure modes and deserve different handling.
The key distinction: retry is appropriate for network-level errors and 5xx server errors. It is not appropriate for
4xx client errors (a malformed request won’t succeed on retry) or for 401 (handled by token refresh).
Create Sources/NetworkingKit/RetryPolicy.swift:
import Foundation
// Configures how many times and how aggressively to retry failed requests.
public struct RetryPolicy: Sendable {
public let maxAttempts: Int
public let baseDelay: TimeInterval
public static let `default` = RetryPolicy(maxAttempts: 3, baseDelay: 0.5)
public static let aggressive = RetryPolicy(maxAttempts: 5, baseDelay: 0.25)
public static let none = RetryPolicy(maxAttempts: 1, baseDelay: 0)
public init(maxAttempts: Int, baseDelay: TimeInterval) {
self.maxAttempts = maxAttempts
self.baseDelay = baseDelay
}
}
Add a withRetry wrapper function. This is a free function rather than a method on LiveAPIClient so it can be
composed and tested independently:
// Executes `operation` up to `policy.maxAttempts` times.
// Retries on network errors and 5xx responses; propagates 4xx immediately.
public func withRetry<T: Sendable>(
policy: RetryPolicy,
operation: @Sendable () async throws -> T
) async throws -> T {
var lastError: Error = NetworkError.noData
for attempt in 0..<policy.maxAttempts {
do {
return try await operation()
} catch let error as NetworkError {
switch error {
case .httpError(let statusCode) where (400..<500).contains(statusCode):
// Client errors won't benefit from retrying.
throw error
default:
lastError = error
}
} catch {
// Catch underlying URLSession errors (timeout, connection lost, etc.)
lastError = error
}
// Don't sleep after the last attempt.
if attempt < policy.maxAttempts - 1 {
let delay = policy.baseDelay * pow(2.0, Double(attempt))
try await Task.sleep(for: .seconds(delay))
}
}
throw lastError
}
The delay formula baseDelay * 2^attempt produces 0.5s → 1.0s → 2.0s for the default policy. This is classic
exponential backoff — each successive retry backs off further, reducing pressure on an already struggling server.
Apple Docs:
Task.sleep(for:)— Swift Standard Library
Integrate retry into LiveAPIClient by wrapping the session.data(for:) call:
// In LiveAPIClient.request(_:):
let (data, response) = try await withRetry(policy: retryPolicy) {
try await self.session.data(for: urlRequest)
}
Add retryPolicy to the LiveAPIClient initializer:
public init(
session: URLSession = .shared,
interceptors: [any RequestInterceptor] = [],
tokenRefresher: TokenRefresher? = nil,
retryPolicy: RetryPolicy = .default // ← New parameter
) { ... }
Checkpoint: To test retry behavior, configure
MockURLProtocolto fail with aURLError(.timedOut)for the first two attempts and succeed on the third. The call should return the decoded value after two sleeps. Confirm the delays increase between attempts by capturing timestamps in the mock handler.
Step 6: Response Caching
URLSession has built-in HTTP caching, but it relies on the server sending well-formed Cache-Control headers —
something many APIs don’t do correctly. A client-side cache gives you precise control over what is cached, for how long,
and how stale responses are served.
Create Sources/NetworkingKit/Interceptors/CachingInterceptor.swift:
import Foundation
// A cached HTTP response, paired with the metadata needed to
// determine whether it is still fresh.
public final class CachedResponse: @unchecked Sendable {
public let data: Data
public let response: HTTPURLResponse
public let timestamp: Date
public let maxAge: TimeInterval
public var isFresh: Bool {
Date().timeIntervalSince(timestamp) < maxAge
}
public init(
data: Data,
response: HTTPURLResponse,
timestamp: Date = Date(),
maxAge: TimeInterval
) {
self.data = data
self.response = response
self.timestamp = timestamp
self.maxAge = maxAge
}
}
// An in-memory cache backed by NSCache.
// NSCache automatically evicts entries under memory pressure.
public final class ResponseCache: @unchecked Sendable {
private let storage = NSCache<NSString, CachedResponse>()
public init(countLimit: Int = 100) {
storage.countLimit = countLimit
}
public func store(_ response: CachedResponse, for key: String) {
storage.setObject(response, forKey: key as NSString)
}
public func retrieve(for key: String) -> CachedResponse? {
storage.object(forKey: key as NSString)
}
public func remove(for key: String) {
storage.removeObject(forKey: key as NSString)
}
}
Apple Docs:
NSCache— Foundation.NSCachediffers fromDictionaryin two important ways: it automatically evicts objects when memory is tight, and it is thread-safe by default.
Now build the interceptor that checks the cache before allowing a request to proceed:
public struct CachingInterceptor: RequestInterceptor {
private let cache: ResponseCache
public init(cache: ResponseCache = ResponseCache()) {
self.cache = cache
}
public func intercept(_ request: URLRequest) async throws -> URLRequest {
guard request.httpMethod == "GET",
let urlString = request.url?.absoluteString else {
// Only cache GET requests.
return request
}
if let cached = cache.retrieve(for: urlString), cached.isFresh {
// Attach a custom header so the client knows a cache hit occurred.
// The actual short-circuiting happens in the response handler below.
var modified = request
modified.setValue("HIT", forHTTPHeaderField: "X-Cache")
return modified
}
// Add an ETag header if one was stored from a previous response.
// The server can respond with 304 Not Modified if the content hasn't changed.
if let cached = cache.retrieve(for: urlString),
let etag = cached.response.value(forHTTPHeaderField: "ETag") {
var modified = request
modified.setValue(etag, forHTTPHeaderField: "If-None-Match")
return modified
}
return request
}
}
The interceptor pattern as implemented only transforms requests. To serve cached responses and store new ones, extend
LiveAPIClient with a caching layer around the data task. Add this helper to APIClient.swift:
private func executeWithCaching(
request: URLRequest,
cache: ResponseCache
) async throws -> (Data, HTTPURLResponse) {
let key = request.url?.absoluteString ?? ""
// Serve from cache if the response is still fresh.
if let cached = cache.retrieve(for: key), cached.isFresh {
return (cached.data, cached.response)
}
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.noData
}
// Parse Cache-Control: max-age=<seconds>
let maxAge = parseMaxAge(from: httpResponse) ?? 300 // Default: 5 minutes
let entry = CachedResponse(
data: data,
response: httpResponse,
maxAge: maxAge
)
cache.store(entry, for: key)
return (data, httpResponse)
}
private func parseMaxAge(from response: HTTPURLResponse) -> TimeInterval? {
guard let cacheControl = response.value(forHTTPHeaderField: "Cache-Control") else {
return nil
}
// Parse "max-age=300" out of a Cache-Control header value.
let parts = cacheControl.components(separatedBy: ",")
for part in parts {
let trimmed = part.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("max-age="),
let value = Double(trimmed.dropFirst("max-age=".count)) {
return value
}
}
return nil
}
Checkpoint: Fetch
PixarAPIEndpoint.filmstwice in quick succession. Add a print statement insideexecuteWithCachingto log whether the response came from the cache or the network. The second call should log a cache hit and complete without making a URLSession request.
Step 7: Structured Request Logging
Once the network layer has retry, caching, and auth refresh running, debugging becomes harder — a single request()
call can trigger multiple underlying URLSession calls. Structured logging with OSLog makes it possible to trace
exactly what happened.
Create Sources/NetworkingKit/Interceptors/LoggingInterceptor.swift:
import Foundation
import OSLog
// Logs outgoing requests and incoming responses using OSLog.
// Authorization header values are redacted to prevent token leakage in logs.
public struct LoggingInterceptor: RequestInterceptor {
private let logger = Logger(
subsystem: "com.cocoabytes.NetworkingKit",
category: "HTTP"
)
// OSSignposter for measuring request duration in Instruments.
private let signposter = OSSignposter(
subsystem: "com.cocoabytes.NetworkingKit",
category: "HTTP"
)
public init() {}
public func intercept(_ request: URLRequest) async throws -> URLRequest {
let method = request.httpMethod ?? "UNKNOWN"
let url = request.url?.absoluteString ?? "nil"
logger.debug("→ \(method) \(url)")
// Log headers, but replace Authorization token value with "<redacted>".
if let headers = request.allHTTPHeaderFields {
var safeHeaders = headers
if safeHeaders["Authorization"] != nil {
safeHeaders["Authorization"] = "<redacted>"
}
logger.debug(" Headers: \(safeHeaders)")
}
return request
}
}
To log response timing, add a response-aware wrapper. Because logging applies to both the request and the response, add
a logRequest method to LiveAPIClient rather than putting timing entirely inside the interceptor:
private func loggedDataTask(
request: URLRequest,
signposter: OSSignposter
) async throws -> (Data, URLResponse) {
let state = signposter.beginInterval(
"HTTP Request",
id: signposter.makeSignpostID(),
"\(request.httpMethod ?? "") \(request.url?.path ?? "")"
)
defer { signposter.endInterval("HTTP Request", state) }
return try await session.data(for: request)
}
Apple Docs:
OSLog— OS Framework.OSSignposter— OS Framework. Signpost intervals appear as colored spans in the Instruments os_signpost instrument, making it trivial to see which network requests account for latency.Checkpoint: Run the app with the Instruments Logging template. Filter by subsystem
com.cocoabytes.NetworkingKit. You should see one log line per outgoing request and one per response, with timing. Switch to the os_signpost instrument to see duration spans for each HTTP request. Authorization tokens should appear as<redacted>in every log entry.
Step 8: Unit Tests with MockURLProtocol
Real network calls have no place in a unit test suite — they’re slow, flaky, and dependent on external infrastructure.
URLProtocol is the standard mechanism for
intercepting URLSession requests in tests. By registering a custom URLProtocol subclass, you can return any response
you want without opening a socket.
Create Tests/NetworkingKitTests/MockURLProtocol.swift:
import Foundation
// A URLProtocol subclass that intercepts requests and returns
// a configurable (response, data) pair — or throws an error.
// Register it in a URLSessionConfiguration to use it in tests.
final class MockURLProtocol: URLProtocol, @unchecked Sendable {
// Set this before each test to define what the mock returns.
static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool {
// Handle every request so nothing leaks to the real network.
return true
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
return request
}
override func startLoading() {
guard let handler = MockURLProtocol.requestHandler else {
client?.urlProtocol(self, didFailWithError: URLError(.unknown))
return
}
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}
Add a test helper that creates a URLSession configured to use MockURLProtocol:
extension URLSession {
static func mocked() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
return URLSession(configuration: config)
}
}
Create Tests/NetworkingKitTests/APIClientTests.swift:
import Testing
import Foundation
@testable import NetworkingKit
@Suite("APIClient Tests")
struct APIClientTests {
// Returns a JSON-encoded array of two Pixar films.
private func pixarFilmsJSON() -> Data {
let json = """
[
{
"id": "1",
"title": "Toy Story",
"release_year": 1995,
"studio": "Pixar",
"synopsis": "A cowboy doll is threatened by a new spaceman toy."
},
{
"id": "2",
"title": "WALL-E",
"release_year": 2008,
"studio": "Pixar",
"synopsis": "A robot falls in love while cleaning up Earth."
}
]
"""
return json.data(using: .utf8)!
}
@Test("Successful request decodes response correctly")
func successfulRequest() async throws {
MockURLProtocol.requestHandler = { _ in
let response = HTTPURLResponse(
url: URL(string: "https://api.pixarapi.example.com/v1/films")!,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)!
return (response, self.pixarFilmsJSON())
}
let client = LiveAPIClient(session: .mocked())
let films: [PixarFilm] = try await client.request(PixarAPIEndpoint.films)
#expect(films.count == 2)
#expect(films[0].title == "Toy Story")
#expect(films[1].releaseYear == 2008)
}
@Test("HTTP 404 throws NetworkError.httpError")
func notFoundThrowsError() async throws {
MockURLProtocol.requestHandler = { _ in
let response = HTTPURLResponse(
url: URL(string: "https://api.pixarapi.example.com/v1/films/999")!,
statusCode: 404,
httpVersion: nil,
headerFields: nil
)!
return (response, Data())
}
let client = LiveAPIClient(session: .mocked())
await #expect(throws: NetworkError.httpError(statusCode: 404)) {
let _: PixarFilm = try await client.request(PixarAPIEndpoint.filmDetail(id: "999"))
}
}
}
Create Tests/NetworkingKitTests/RetryTests.swift:
import Testing
import Foundation
@testable import NetworkingKit
@Suite("Retry Policy Tests")
struct RetryTests {
@Test("Retries on timeout and succeeds on third attempt")
func retryOnTimeout() async throws {
var attemptCount = 0
let result = try await withRetry(policy: RetryPolicy(maxAttempts: 3, baseDelay: 0.01)) {
attemptCount += 1
if attemptCount < 3 {
throw URLError(.timedOut)
}
return "Wall-E reporting for duty"
}
#expect(result == "Wall-E reporting for duty")
#expect(attemptCount == 3)
}
@Test("Does not retry on 400 client error")
func noRetryOn4xx() async throws {
var attemptCount = 0
await #expect(throws: NetworkError.httpError(statusCode: 400)) {
_ = try await withRetry(policy: RetryPolicy(maxAttempts: 3, baseDelay: 0.01)) {
attemptCount += 1
throw NetworkError.httpError(statusCode: 400)
}
}
// Should have thrown immediately without retrying.
#expect(attemptCount == 1)
}
@Test("Exhausts all attempts and re-throws last error")
func exhaustsAttempts() async throws {
var attemptCount = 0
await #expect(throws: NetworkError.httpError(statusCode: 503)) {
_ = try await withRetry(policy: RetryPolicy(maxAttempts: 3, baseDelay: 0.01)) {
attemptCount += 1
throw NetworkError.httpError(statusCode: 503)
}
}
#expect(attemptCount == 3)
}
}
Create Tests/NetworkingKitTests/CachingTests.swift:
import Testing
import Foundation
@testable import NetworkingKit
@Suite("Response Cache Tests")
struct CachingTests {
private func makeHTTPResponse(statusCode: Int = 200) -> HTTPURLResponse {
HTTPURLResponse(
url: URL(string: "https://api.pixarapi.example.com/v1/films")!,
statusCode: statusCode,
httpVersion: nil,
headerFields: ["Cache-Control": "max-age=300"]
)!
}
@Test("Fresh cached response is returned without network access")
func servesFromCache() {
let cache = ResponseCache()
let key = "https://api.pixarapi.example.com/v1/films"
let data = "{\"id\":\"1\"}".data(using: .utf8)!
let entry = CachedResponse(
data: data,
response: makeHTTPResponse(),
timestamp: Date(),
maxAge: 300
)
cache.store(entry, for: key)
let retrieved = cache.retrieve(for: key)
#expect(retrieved != nil)
#expect(retrieved?.isFresh == true)
#expect(retrieved?.data == data)
}
@Test("Stale cached response is not served as fresh")
func staleResponseNotFresh() {
let cache = ResponseCache()
let key = "https://api.pixarapi.example.com/v1/films"
let data = "{}".data(using: .utf8)!
// Timestamp 10 minutes in the past with a 5-minute max age.
let entry = CachedResponse(
data: data,
response: makeHTTPResponse(),
timestamp: Date(timeIntervalSinceNow: -600),
maxAge: 300
)
cache.store(entry, for: key)
let retrieved = cache.retrieve(for: key)
#expect(retrieved?.isFresh == false)
}
@Test("Cache evicts on explicit removal")
func explicitRemoval() {
let cache = ResponseCache()
let key = "https://api.pixarapi.example.com/v1/films"
let entry = CachedResponse(
data: Data(),
response: makeHTTPResponse(),
maxAge: 300
)
cache.store(entry, for: key)
cache.remove(for: key)
#expect(cache.retrieve(for: key) == nil)
}
}
Run the tests:
cd NetworkingKit
swift test
Test Suite 'All tests' started
Test Suite 'APIClientTests' started
✓ Successful request decodes response correctly (0.012s)
✓ HTTP 404 throws NetworkError.httpError (0.003s)
Test Suite 'RetryTests' started
✓ Retries on timeout and succeeds on third attempt (0.055s)
✓ Does not retry on 400 client error (0.002s)
✓ Exhausts all attempts and re-throws last error (0.032s)
Test Suite 'CachingTests' started
✓ Fresh cached response is returned without network access (0.001s)
✓ Stale cached response is not served as fresh (0.001s)
✓ Cache evicts on explicit removal (0.001s)
Test Suite 'All tests' passed (0.107s)
Executed 8 tests, with 0 failures.
Checkpoint: All eight tests pass. Notice that
RetryTestsruns in about 55ms total despite three retry attempts — thebaseDelayof0.01seconds keeps the test suite fast. In production, useRetryPolicy.default(0.5s base delay). In tests, always inject a fast policy.
Where to Go From Here?
Congratulations — you’ve built NetworkingKit, a production-grade Swift Package that handles the full lifecycle of an authenticated, fault-tolerant HTTP client.
Here’s what you built:
- Protocol-based endpoint modeling with
APIEndpointandPixarAPIEndpoint, making every API surface a type-safe, exhaustive Swift enum. - A generic
APIClientbacked byURLSessionthat decodes anyDecodableresponse type with a single method call. - The interceptor pipeline — a composable chain of
RequestInterceptorconformances that keeps cross-cutting concerns (auth, caching, logging) out of the core client. - Concurrent-safe token refresh with a Swift actor that coalesces simultaneous 401 responses into a single refresh attempt, eliminating duplicate token exchanges.
- Exponential backoff retry that distinguishes retryable transient errors from non-retryable client errors, so
HTTP 400fails immediately whileURLError(.timedOut)gets three chances. - In-memory response caching backed by
NSCache, withCache-Control: max-ageparsing and ETag support for conditional requests. - Structured logging with
OSLogandOSSignposter, including Authorization header redaction and Instruments integration. - A complete unit test suite using
MockURLProtocol— zero real network calls, eight tests, under 200ms.
Ideas for extending NetworkingKit:
- Add a
MultipartFormDataEncoderto support file and image uploads viamultipart/form-datarequests. - Implement a
WebSocketAdapterthat wrapsURLSessionWebSocketTaskbehind the same interceptor pipeline. - Replace the in-memory
NSCachewith a disk-backed cache usingFileManagerfor persistent caching across app launches. - Add a
RequestPriorityproperty toAPIEndpointand map it toURLRequest.NetworkServiceTypefor quality-of-service tuning. - Instrument the retry delay with a
Clockabstraction (instead of callingTask.sleepdirectly) so that retry tests run without real delays.