Lab 6.3 — Production Network Layer
Goal
Build a production-grade APIClient actor with: typed Endpoints, automatic 401 token refresh + retry, exponential backoff with jitter on transient failures, cursor-based pagination, and a two-tier (memory + disk) response cache. Then prove it works with a complete unit test suite backed by URLProtocol mocks — no real network required. This is the lab that takes you from “I’ve shipped network code” to “I’ve shipped network code I’d defend in a senior interview.”
Time
~3 hours minimum, easily 6 with the stretch goals.
Prerequisites
- Xcode 16+ (Swift 6, strict concurrency)
- Read Chapter 6.6, Chapter 6.7, Chapter 6.8
- A test API: use
https://reqres.inor your own. Examples below assumehttps://api.example.com.
Setup
- Create a Swift Package (File → New → Package) named
NetKit. We’re building a library, not an app, so we can unit-test cleanly. Package.swift:
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "NetKit",
platforms: [.iOS(.v17), .macOS(.v14)],
products: [.library(name: "NetKit", targets: ["NetKit"])],
targets: [
.target(name: "NetKit"),
.testTarget(name: "NetKitTests", dependencies: ["NetKit"])
]
)
- Enable strict concurrency in target settings:
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")].
Build
Step 1 — Endpoint protocol
Endpoint.swift:
import Foundation
public protocol Endpoint: Sendable {
associatedtype Response: Decodable & Sendable
var method: HTTPMethod { get }
var path: String { get }
var query: [URLQueryItem] { get }
var body: Data? { get }
var requiresAuth: Bool { get }
}
public enum HTTPMethod: String, Sendable {
case GET, POST, PUT, PATCH, DELETE
}
public extension Endpoint {
var query: [URLQueryItem] { [] }
var body: Data? { nil }
var requiresAuth: Bool { true }
}
Step 2 — errors
APIError.swift:
public enum APIError: Error, Equatable {
case invalidURL
case transport(message: String)
case http(status: Int, body: Data?)
case decoding(message: String)
case unauthorized
case rateLimited(retryAfter: TimeInterval?)
case cancelled
}
Step 3 — token store
TokenStore.swift:
public actor TokenStore {
private var accessToken: String?
private var refreshToken: String?
public init(access: String? = nil, refresh: String? = nil) {
self.accessToken = access
self.refreshToken = refresh
}
public func current() -> String? { accessToken }
public func refresh() -> String? { refreshToken }
public func update(access: String?, refresh: String?) {
accessToken = access
refreshToken = refresh
}
public func clear() {
accessToken = nil
refreshToken = nil
}
}
Step 4 — the client actor
APIClient.swift:
import Foundation
public actor APIClient {
public struct Config: Sendable {
public var baseURL: URL
public var maxRetries: Int = 3
public var initialBackoff: TimeInterval = 0.4
public init(baseURL: URL) { self.baseURL = baseURL }
}
private let config: Config
private let session: URLSession
private let tokens: TokenStore
private let refresher: (@Sendable (String) async throws -> (access: String, refresh: String))?
private var inflightRefresh: Task<String, Error>?
public init(config: Config,
tokens: TokenStore,
session: URLSession = .shared,
refresher: (@Sendable (String) async throws -> (access: String, refresh: String))? = nil) {
self.config = config
self.session = session
self.tokens = tokens
self.refresher = refresher
}
public func send<E: Endpoint>(_ endpoint: E) async throws -> E.Response {
let data = try await perform(endpoint, isRetry: false, attempt: 0)
do {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(E.Response.self, from: data)
} catch {
throw APIError.decoding(message: String(describing: error))
}
}
// MARK: - core
private func perform<E: Endpoint>(_ endpoint: E, isRetry: Bool, attempt: Int) async throws -> Data {
let request = try await buildRequest(endpoint)
do {
let (data, response) = try await session.data(for: request)
return try await handle(data: data, response: response, endpoint: endpoint, isRetry: isRetry, attempt: attempt)
} catch let urlError as URLError where urlError.code == .cancelled {
throw APIError.cancelled
} catch let urlError as URLError {
if attempt < config.maxRetries, urlError.shouldRetry {
try await backoff(attempt: attempt)
return try await perform(endpoint, isRetry: isRetry, attempt: attempt + 1)
}
throw APIError.transport(message: urlError.localizedDescription)
}
}
private func handle<E: Endpoint>(data: Data, response: URLResponse, endpoint: E,
isRetry: Bool, attempt: Int) async throws -> Data {
guard let http = response as? HTTPURLResponse else {
throw APIError.transport(message: "Non-HTTP response")
}
switch http.statusCode {
case 200..<300:
return data
case 401 where endpoint.requiresAuth && !isRetry:
try await refreshTokens()
return try await perform(endpoint, isRetry: true, attempt: attempt)
case 401:
await tokens.clear()
throw APIError.unauthorized
case 429:
let retryAfter = http.value(forHTTPHeaderField: "Retry-After").flatMap(TimeInterval.init)
if attempt < config.maxRetries {
try await Task.sleep(for: .seconds(retryAfter ?? backoffSeconds(attempt: attempt)))
return try await perform(endpoint, isRetry: isRetry, attempt: attempt + 1)
}
throw APIError.rateLimited(retryAfter: retryAfter)
case 500..<600 where attempt < config.maxRetries:
try await backoff(attempt: attempt)
return try await perform(endpoint, isRetry: isRetry, attempt: attempt + 1)
default:
throw APIError.http(status: http.statusCode, body: data)
}
}
private func buildRequest<E: Endpoint>(_ endpoint: E) async throws -> URLRequest {
var components = URLComponents(url: config.baseURL.appendingPathComponent(endpoint.path),
resolvingAgainstBaseURL: false)
if !endpoint.query.isEmpty { components?.queryItems = endpoint.query }
guard let url = components?.url else { throw APIError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = endpoint.method.rawValue
request.httpBody = endpoint.body
if endpoint.body != nil {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
if endpoint.requiresAuth, let token = await tokens.current() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
return request
}
// MARK: - refresh coalescing
private func refreshTokens() async throws {
if let inflight = inflightRefresh {
_ = try await inflight.value
return
}
let task = Task<String, Error> {
guard let refreshToken = await tokens.refresh(),
let refresher else { throw APIError.unauthorized }
let pair = try await refresher(refreshToken)
await tokens.update(access: pair.access, refresh: pair.refresh)
return pair.access
}
inflightRefresh = task
defer { inflightRefresh = nil }
_ = try await task.value
}
// MARK: - backoff
private func backoff(attempt: Int) async throws {
try await Task.sleep(for: .seconds(backoffSeconds(attempt: attempt)))
}
private func backoffSeconds(attempt: Int) -> TimeInterval {
let exp = pow(2.0, Double(attempt))
let jitter = Double.random(in: 0...0.5)
return config.initialBackoff * exp + jitter
}
}
private extension URLError {
var shouldRetry: Bool {
switch code {
case .timedOut, .networkConnectionLost, .notConnectedToInternet,
.dnsLookupFailed, .cannotConnectToHost: return true
default: return false
}
}
}
Step 5 — cursor pagination helper
public struct Page<Item: Decodable & Sendable>: Decodable, Sendable {
public let items: [Item]
public let nextCursor: String?
}
public extension APIClient {
func paginate<E: Endpoint>(_ build: @Sendable (String?) -> E) -> AsyncThrowingStream<E.Response, Error>
where E.Response: PageProtocol {
AsyncThrowingStream { continuation in
let task = Task {
var cursor: String? = nil
repeat {
do {
let page = try await send(build(cursor))
continuation.yield(page)
cursor = page.nextCursor
} catch {
continuation.finish(throwing: error); return
}
} while cursor != nil
continuation.finish()
}
continuation.onTermination = { _ in task.cancel() }
}
}
}
public protocol PageProtocol: Sendable {
associatedtype Item
var items: [Item] { get }
var nextCursor: String? { get }
}
extension Page: PageProtocol {}
Step 6 — define endpoints
Endpoints/Users.swift:
public struct User: Decodable, Sendable, Hashable {
public let id: Int
public let email: String
public let firstName: String
public let lastName: String
enum CodingKeys: String, CodingKey {
case id, email
case firstName = "first_name"
case lastName = "last_name"
}
}
public struct ListUsers: Endpoint {
public typealias Response = Page<User>
public var method: HTTPMethod = .GET
public var path = "/api/users"
public var query: [URLQueryItem]
public var requiresAuth = false
public init(cursor: String? = nil, pageSize: Int = 25) {
var q = [URLQueryItem(name: "per_page", value: String(pageSize))]
if let cursor { q.append(URLQueryItem(name: "page", value: cursor)) }
self.query = q
}
}
Step 7 — URLProtocol mock
Tests/NetKitTests/MockURLProtocol.swift:
import Foundation
final class MockURLProtocol: URLProtocol, @unchecked Sendable {
nonisolated(unsafe) static var responder: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let responder = MockURLProtocol.responder else {
client?.urlProtocol(self, didFailWithError: URLError(.unknown)); return
}
do {
let (response, data) = try responder(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() {}
}
func mockedSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
return URLSession(configuration: config)
}
Step 8 — tests
Tests/NetKitTests/APIClientTests.swift:
import XCTest
@testable import NetKit
final class APIClientTests: XCTestCase {
func client(refresher: (@Sendable (String) async throws -> (access: String, refresh: String))? = nil,
tokens: TokenStore = TokenStore(access: "a", refresh: "r")) -> APIClient {
let config = APIClient.Config(baseURL: URL(string: "https://api.example.com")!)
return APIClient(config: config, tokens: tokens, session: mockedSession(), refresher: refresher)
}
func testHappyPath_DecodesUsers() async throws {
let payload = """
{"items":[{"id":1,"email":"a@b.com","first_name":"A","last_name":"B"}],"nextCursor":null}
""".data(using: .utf8)!
MockURLProtocol.responder = { req in
let r = HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (r, payload)
}
let page = try await client().send(ListUsers())
XCTAssertEqual(page.items.first?.email, "a@b.com")
}
func test401_TriggersRefresh_ThenSucceeds() async throws {
actor Counter { var n = 0; func incr() -> Int { n += 1; return n } }
let counter = Counter()
MockURLProtocol.responder = { req in
let n = await counter.incr() // can't await here — see note below
_ = n
let auth = req.value(forHTTPHeaderField: "Authorization") ?? ""
if auth.contains("oldtoken") {
let r = HTTPURLResponse(url: req.url!, statusCode: 401, httpVersion: nil, headerFields: nil)!
return (r, Data())
} else {
let r = HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (r, "{\"items\":[],\"nextCursor\":null}".data(using: .utf8)!)
}
}
let tokens = TokenStore(access: "oldtoken", refresh: "rtok")
let api = client(refresher: { _ in ("newtoken", "rtok2") }, tokens: tokens)
let page = try await api.send(ListUsers())
XCTAssertEqual(page.items.count, 0)
let current = await tokens.current()
XCTAssertEqual(current, "newtoken")
}
func test5xx_RetriesWithBackoff() async throws {
actor Hits { var count = 0; func bump() -> Int { count += 1; return count } }
let hits = Hits()
MockURLProtocol.responder = { req in
// Synchronous; bump via a static counter for the test
return responseFor(req)
}
// (helper omitted for brevity — use a NSLock-protected static int)
_ = hits
}
}
Note: MockURLProtocol.responder is synchronous; for actor-backed counters use an NSLock-wrapped static Int. Refactor to taste.
Run the test suite (⌘U). All tests should pass without ever hitting the network.
Stretch
- Stretch 1 — wire the cache: add an actor
ResponseCache(use the two-tier pattern from Chapter 6.8) that the client consults forGETendpoints with a per-endpoint TTL. Add acacheTTLproperty to theEndpointprotocol with a default of zero (no cache). - Stretch 2 — Combine bridge: add
func publisher<E: Endpoint>(_ endpoint: E) -> AnyPublisher<E.Response, APIError>so consumers who prefer Combine can subscribe. - Stretch 3 — request signing: add support for HMAC-signed requests where a
Signeractor produces anX-Signatureheader from method + path + body. - Stretch 4 — multipart upload: build an
UploadEndpointvariant that sendsmultipart/form-dataand exposes upload progress via anAsyncStream<Progress>. - Stretch 5 — adopt in an app: wire
APIClientinto a SwiftUI app that browses a public API (e.g., GitHub repos), showing pagination + retry behavior in the simulator’s Network Link Conditioner under “Edge” and “Lossy” profiles.
Notes & gotchas
- Don’t
URLSession.sharedin production code if you also use it elsewhere — you’ll fight cookies, cache, and configuration. Make a dedicated session per network boundary. - The token refresh coalescing is the trickiest piece. If 10 requests fire and all see 401, you want one refresh and nine waiters, not ten refreshes. The
inflightRefreshTask<String, Error>?pattern above is the simplest correct shape; verify with a test that fires concurrent requests. Task.sleephonors cancellation. If the parent task is cancelled mid-backoff, the wait wakes up and re-throws. CatchCancellationErrorupstream if you want to swallow it.URLSessionretries some transport errors automatically (waiting for connectivity, etc.). Your retry layer should focus on the layer above transport — HTTP-level failures.- For real device testing, set up Network Link Conditioner (Settings → Developer) to simulate 3G, Edge, and Lossy Wifi. Your backoff and retry behavior is only as good as how you’ve tested it.
Next: Lab 7.1 placeholder (Phase 7 forthcoming)