Chapter 7 — Interceptors: retries, caching & API-key injection

Why a pipeline

  • Auth — attach Authorization, refresh on 401.
  • API key — attach an X-API-Key header to every request.
  • Retry — re-send transient failures (a 503, a timeout) with backoff.
  • Logging — record every request/response (Chapter 11).
flowchart LR Req["Endpoint → URLRequest"] --> A1["APIKey.adapt"] --> A2["Auth.adapt"] --> Send["URLSession"] Send --> R1["Retry.shouldRetry?"] --> R2["Auth.shouldRetry? (401→refresh)"] R2 -->|"retry"| A2 R2 -->|"done"| Decode["validate + decode"]

The interceptor protocol

// Chirp/Networking/RequestInterceptor.swift
import Foundation

enum RetryResult {
    case doNotRetry
    case retry                          // retry immediately
    case retryAfter(TimeInterval)       // retry after a delay (backoff)
}

protocol RequestInterceptor: Sendable {
    // Mutate the outgoing request (add headers, etc.). Default: unchanged.
    func adapt(_ request: URLRequest) async throws -> URLRequest

    // After a response/error, decide whether to send again. Default: don't.
    func retry(_ request: URLRequest, response: HTTPURLResponse?, data: Data?,
               error: Error?, attempt: Int) async throws -> RetryResult
}

extension RequestInterceptor {
    func adapt(_ request: URLRequest) async throws -> URLRequest { request }
    func retry(_ request: URLRequest, response: HTTPURLResponse?, data: Data?,
               error: Error?, attempt: Int) async throws -> RetryResult { .doNotRetry }
}

Running the chain in the client

// Chirp/Networking/APIClient.swift  (send, generalized)
func send<Response>(_ endpoint: Endpoint<Response>) async throws -> Response {
    let baseRequest = try makeRequest(for: endpoint)
    var attempt = 0

    while true {
        // 1. Adapt: run the request through every interceptor.
        var request = baseRequest
        for interceptor in interceptors { request = try await interceptor.adapt(request) }

        // 2. Send.
        let (data, response) = try await performOrCaptureError(request)
        let http = response as? HTTPURLResponse

        // 3. Ask interceptors (in order) whether to retry.
        var decision = RetryResult.doNotRetry
        for interceptor in interceptors {
            decision = try await interceptor.retry(request, response: http, data: data.value,
                                                   error: data.error, attempt: attempt)
            if case .doNotRetry = decision { continue } else { break }
        }

        switch decision {
        case .doNotRetry:
            let payload = try data.get()                  // rethrow transport errors
            try validate(response!, data: payload)
            return try decode(payload)
        case .retry:
            attempt += 1
        case .retryAfter(let delay):
            try await Task.sleep(for: .seconds(delay)); attempt += 1
        }
    }
}

Interceptor 1 — Auth (refactored from Chapter 6)

struct AuthInterceptor: RequestInterceptor {
    let tokenProvider: TokenProvider

    func adapt(_ request: URLRequest) async throws -> URLRequest {
        var request = request
        // Only attach to requests that need it (marked via a header the client set, or endpoint flag).
        let token = try await tokenProvider.validAccessToken()
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        return request
    }

    func retry(_ request: URLRequest, response: HTTPURLResponse?, data: Data?,
               error: Error?, attempt: Int) async throws -> RetryResult {
        guard response?.statusCode == 401, attempt == 0 else { return .doNotRetry }
        _ = try await tokenProvider.refreshBundle()      // single-flight refresh
        return .retry                                    // re-adapt (fresh token) and resend once
    }
}

Interceptor 2 — API-key injection

struct APIKeyInterceptor: RequestInterceptor {
    let apiKey: String        // from Info.plist / xcconfig / environment, not hardcoded

    func adapt(_ request: URLRequest) async throws -> URLRequest {
        var request = request
        request.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
        return request
    }
}

Interceptor 3 — Retry with exponential backoff

struct RetryInterceptor: RequestInterceptor {
    let maxAttempts: Int = 3

    func retry(_ request: URLRequest, response: HTTPURLResponse?, data: Data?,
               error: Error?, attempt: Int) async throws -> RetryResult {
        guard attempt < maxAttempts - 1 else { return .doNotRetry }

        // Only retry SAFE, TRANSIENT failures.
        let isTransientStatus = [408, 429, 500, 502, 503, 504].contains(response?.statusCode ?? 0)
        let isTransientError = (error as? URLError).map {
            [.timedOut, .networkConnectionLost, .cannotConnectToHost].contains($0.code)
        } ?? false
        let isIdempotent = ["GET", "PUT", "DELETE"].contains(request.httpMethod ?? "")

        guard (isTransientStatus || isTransientError), isIdempotent else { return .doNotRetry }

        // Exponential backoff with jitter: 0.5s, 1s, 2s (± randomness to avoid thundering herds).
        let delay = pow(2.0, Double(attempt)) * 0.5 + Double.random(in: 0...0.3)
        return .retryAfter(delay)
    }
}
  • Only transient failures. A 400 or 404 means "you asked wrong" — retrying won't help and wastes
  • Only idempotent methods. Retrying a GET is free; retrying a POST /posts might create the post
  • Exponential backoff with jitter. Wait longer between each attempt (0.5s, 1s, 2s), plus a little

Caching responses

let config = URLSessionConfiguration.default
config.urlCache = URLCache(memoryCapacity: 20_000_000, diskCapacity: 100_000_000)  // 20MB RAM, 100MB disk
config.requestCachePolicy = .useProtocolCachePolicy   // respect the server's caching headers
let session = URLSession(configuration: config)
// on Endpoint:
var cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy
// pull-to-refresh sets .reloadIgnoringLocalCacheData to force a fresh fetch

Cache invalidation is the hard part. A cached feed can go stale after you post. The pragmatic rules: cache aggressively for immutable data (an image at a content-addressed URL never changes — cache forever), cautiously for mutable data (short freshness + pull-to-refresh), and invalidate after a mutation (post → refetch the feed). "There are only two hard things in computer science…" — respect it.

Composing the pipeline

let client = APIClient(
    baseURL: Config.baseURL,
    session: cachingSession,
    interceptors: [
        APIKeyInterceptor(apiKey: Config.apiKey),
        AuthInterceptor(tokenProvider: tokenProvider),
        RetryInterceptor(maxAttempts: 3),
        // LoggingInterceptor(...) — Chapter 11
    ])

What we built

  • Generalized cross-cutting logic into a RequestInterceptor pipeline (adapt + retry), turning
  • Refactored auth into an interceptor (attach token; refresh + retry on 401), and added
  • Built a retry interceptor that re-sends only transient, idempotent failures with **exponential
  • Added response caching via URLCache + Cache-Control, per-endpoint cache policy, and the

Mental model to take away

  • Cross-cutting concerns belong in composable interceptors — each does one thing (adapt the
  • Retry only transient, idempotent failures, with backoff + jitter; never retry 4xx or bare
  • Use URLCache + server caching headers for free HTTP caching; ETag/If-None-Match304 to
  • The order and set of interceptors is configuration — the pipeline is how the networking layer