Chapter 7 — Interceptors: retries, caching & API-key injection
Why a pipeline
- Auth — attach
Authorization, refresh on401. - API key — attach an
X-API-Keyheader 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
400or404means "you asked wrong" — retrying won't help and wastes - Only idempotent methods. Retrying a
GETis free; retrying aPOST /postsmight 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
RequestInterceptorpipeline (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 (
adaptthe - Retry only transient, idempotent failures, with backoff + jitter; never retry
4xxor bare - Use
URLCache+ server caching headers for free HTTP caching; ETag/If-None-Match→304to - The order and set of interceptors is configuration — the pipeline is how the networking layer