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.in or your own. Examples below assume https://api.example.com.

Setup

  1. Create a Swift Package (File → New → Package) named NetKit. We’re building a library, not an app, so we can unit-test cleanly.
  2. 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"])
    ]
)
  1. 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 for GET endpoints with a per-endpoint TTL. Add a cacheTTL property to the Endpoint protocol 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 Signer actor produces an X-Signature header from method + path + body.
  • Stretch 4 — multipart upload: build an UploadEndpoint variant that sends multipart/form-data and exposes upload progress via an AsyncStream<Progress>.
  • Stretch 5 — adopt in an app: wire APIClient into 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.shared in 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 inflightRefresh Task<String, Error>? pattern above is the simplest correct shape; verify with a test that fires concurrent requests.
  • Task.sleep honors cancellation. If the parent task is cancelled mid-backoff, the wait wakes up and re-throws. Catch CancellationError upstream if you want to swallow it.
  • URLSession retries 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)