How would you design a networking layer shared by many apps and teams: interceptors, mocking, and URLProtocol?

12.6.2 · L8 · Interview

Predict first

A session configuration adds a header and uses a URLProtocol stub. What does the stub see?

let c = URLSessionConfiguration.ephemeral
c.protocolClasses = [Stub.self]
c.httpAdditionalHeaders = ["X-App": "1"]
Stub.handler = { _ in (200, [:], Data()) }
_ = try await URLSession(configuration: c).data(from: URL(string: "https://example.test/")!)
print(Stub.seen.first?.value(forHTTPHeaderField: "X-App") ?? "none")
  • 1
  • none

Mental model. The client layer is the airport: every flight goes through the same security, check-in and tracking, and the control tower can reroute or ground flights, but pilots (features) do not build their own runways.

A shared client should be one small layer with a narrow public API and everything else configurable behind it. Start from a URLSession per configuration, owned by the client (never created by features). Model the pipeline as interceptors (middleware) around the transport: each takes a request and a next function and can act before and after it. Typical interceptors, in order: add static and app headers (user agent, app version, locale, request id), authentication (attach the token; on 401 refresh once through the single refresh coordinator and retry), retry and backoff (idempotent requests only), logging and metrics (with secrets redacted), caching or deduplication of identical in-flight GETs, and decoding and error mapping into a small set of domain errors. Feature code sees try await client.send(Endpoint<User>.get(id)) where an Endpoint<Response> value describes method, path, query, body and the response type, so endpoints are plain data that are easy to test and generate.

Mocking and testing: the best seam is URLProtocol. Subclass it, register it in a configuration’s protocolClasses (for your own sessions) and your stub intercepts every request at the lowest level, so the real client code, including interceptors, runs unchanged and you assert on the actual URLRequest (method, headers, body) and return canned status codes, bodies and errors (including failures that count down, such as two 503s then success). That is more faithful than a protocol-based fake of the whole client, though you still use a protocol at the feature boundary so view models test with fakes. For a platform serving many apps, add: a versioned public API and semantic versioning, a configuration object for base URLs and environments, a way to inject the token provider and logger, feature flags and kill switches, contract tests against the backend schema (or generate the client from it), metrics on latency, error rate and retries per endpoint, a staged rollout for changes, and documentation with a minimal sample app. Guard the layer’s boundaries (no UI imports, no global singletons), and review every new interceptor for ordering effects.

The stub sits at the bottom of the stack, so it receives the request exactly as the real transport would, including configured headers.

Common traps

  • Features creating their own URLSessions and retry logic. Behaviour diverges, and the platform cannot fix it in one place.
  • Faking the whole client in tests only. Interceptors, headers and error mapping go untested; stub at URLProtocol as well.

They might follow up with

  • Where should the 401 refresh live? In one coordinator used by the auth interceptor, and never on the refresh call itself.
  • How do you assert on what the client sent? Capture the URLRequest in a URLProtocol stub.

Related questions

  • 12.3.3 How do you handle access-token expiry, refresh races and 401 loops?
  • 12.5.1 How should a client retry failed requests: what to retry, how long to wait, and what idempotency has to do…
  • 12.6.1 REST, GraphQL or gRPC: what are the client-side trade-offs?

← REST, GraphQL or gRPC: what are the client-side trade-offs?