cswork / libraryOpen practice workspace

Articles

URLSession Interview Questions: HTTP Errors, Cancellation, Caching, and Testability

Prepare for URLSession interviews with an injectable Swift client, HTTP-status handling, cancellation ownership, cache freshness tests, and URLProtocol fixtures

An iOS networking answer is strongest when it distinguishes four events that a single try await can obscure: a transport failure, an HTTP response with an unsuccessful status, a request that the caller no longer wants, and a response served from cache. URLSession provides the transport and configuration machinery; your application still has to define the success contract, cancellation ownership, and tests. This guide uses an original small client exercise rather than claiming any particular Apple-team interview format. The platform facts come from Apple's URLSession documentation and related references.

The article's thesis is a boundary: a successful network transfer is not necessarily a successful application response. A server can deliver a complete HTTP 404 response without a transport error. Conversely, a timeout may occur before you know whether a write reached the server. State these separately before discussing retry or UI behavior.

Interview prompt: design a small testable GET client

Assume a screen fetches a JSON document from an HTTPS endpoint. A 200 with a valid body should be decoded; 404 should become a typed not-found result; 500 may be retried according to a bounded policy; a lost connection should retain its underlying error for diagnosis; and leaving the screen should cancel work or at least prevent a stale result from updating the new screen. The screen may show cached content, but it must disclose when that content is stale under the product's freshness rule. These requirements are exercise assumptions, not URLSession defaults.

One minimal boundary is an injectable session and an explicit status check:

import Foundation

enum HTTPClientError: Error {
    case nonHTTPResponse
    case status(Int, Data)
}

struct HTTPClient {
    let session: URLSession

    func get(_ url: URL) async throws -> Data {
        let (data, response) = try await session.data(from: url)
        guard let http = response as? HTTPURLResponse else {
            throw HTTPClientError.nonHTTPResponse
        }
        guard (200..<300).contains(http.statusCode) else {
            throw HTTPClientError.status(http.statusCode, data)
        }
        return data
    }
}

This is deliberately a narrow illustrative client, not a production networking library. It preserves non-2xx body data for a caller that might parse an error payload; production code should bound retained error-body size and avoid logging secrets. It does not decode JSON, enforce a MIME type, implement retries, or choose a cache policy. Those are separate decisions. HTTPURLResponse carries the HTTP status code, while the try await can throw for failures in performing the task. Treating any returned Data as success is the bug this exercise is designed to catch.

Question 1: Why does HTTP 404 not behave like a thrown transport error?

HTTP is an application protocol over a completed request/response exchange. If the server returns 404 with a response body, URLSession may have successfully delivered the response; the application must inspect HTTPURLResponse.statusCode. Apple documents the HTTP response type separately from URL loading errors. Do not write a catch block that assumes every non-2xx status will be thrown by session.data(from:).

Make the caller contract explicit. A 404 for a missing profile could map to a user-facing not-found state, not a generic offline message. A 401 may require an authentication flow; a 429 may call for a bounded delay under server guidance; a 500 can be a server failure that merits a retry only when the operation is safe. Never hide the status by returning empty data, because an empty successful document and a failed request are different states. For a 204 No Content, the transport/status can be successful while a JSON decoder expecting an object would fail; decide whether an empty body is valid for that endpoint.

Separate decoding from transport and HTTP validation. After get returns, decode the expected model and report a decoding error with enough context to debug schema drift, without dumping sensitive bodies to logs. In tests, assert the three boundaries independently: a simulated transport error, a complete 404 response, and a 200 response with malformed JSON. A single “request failed” test cannot show which layer is broken.

An interviewer may ask whether a timeout on POST can simply be retried. The client may not know whether the server committed the write. Retrying without an idempotency contract can create duplicates. That is a server/API design concern in addition to a URLSession concern. For this guide's GET, a bounded retry may be reasonable, but only after defining cache and freshness requirements. Do not claim that URLSession alone makes business operations idempotent.

Question 2: Who owns cancellation, and what can still arrive afterward?

For task-based APIs, URLSessionTask.cancel() marks a task for cancellation. Apple's task cancellation reference notes that delegate messages may arrive before cancellation is acknowledged, and a completion error uses the cancellation code. The URLError.cancelled reference identifies that error. For Swift async wrappers, do not hard-code your entire UI state machine around one concrete error type without testing the path your app uses; task cancellation and URL loading cancellation can surface at different layers.

The owner should be clear. If a screen starts a Task, it should retain a handle while the work is relevant and cancel it on the lifecycle transition that makes the result obsolete. A generation or request ID can also prevent an older response from replacing a newer search result. Cancellation is not a retroactive guarantee that no callback, delegate event, or already-computed result will be observed. Before committing a result to UI state, confirm that this request still owns that state. This matters when a user opens item A, quickly opens B, and A finishes last.

Do not retry a cancelled request as though it were a transient network error. The caller may have explicitly withdrawn interest. A retry loop should check cancellation before sleeping, after sleeping, and before a new attempt. If several views share one in-flight request, decide whether cancelling one waiter cancels the shared network task or merely detaches that waiter. That is an ownership decision; indiscriminately cancelling the shared task can break other callers.

An interview answer should distinguish user cancellation, timeout, navigation, and process termination. They are not interchangeable. A foreground data task cancelled by a view is different from a background upload intended to continue beyond app suspension. Apple's session configuration reference distinguishes default, ephemeral, and background sessions. Choosing background transfer changes lifecycle and test constraints; do not imply a normal data task continues indefinitely after the app exits.

Question 3: What does URLSession cache, and how would you prove freshness?

Caching is a policy and an HTTP contract, not a blanket promise that repeated requests are always free or always fresh. A session configuration can specify a urlCache and requestCachePolicy; server cache headers and request properties also matter. Apple's cached-data guide and configuration reference describe the knobs. A default session may use persistent caching; an ephemeral session avoids persistent cache storage but may use a private in-memory cache. “Ephemeral means no cache at all” is an inaccurate shortcut.

Define the product requirement first. For public news content, a brief cached result during a network outage might be acceptable. For an account balance or purchase status, showing stale data as current could be harmful. Then choose an HTTP cache policy, server headers, and UI stale-state behavior that fit. A requestCachePolicy such as reload-ignoring-local-cache changes client behavior but does not magically force every upstream proxy or server to deliver newly committed data. Validation requires a controlled response and observed request path.

Test at least these cases: the first fetch populates a cacheable response; a second fetch under the chosen policy is served or revalidated as expected; expiration leads to the intended network behavior; a server update changes what the user sees within the declared freshness window; an offline launch displays a labelled cached value only if the product allows it. If the request includes authentication, make sure the cache key and headers do not leak one user's content into another's session. Avoid testing cache by measuring elapsed milliseconds alone; inspect the stub's request count and the returned version.

The differences among session types matter. Apple's URLCache configuration documentation describes defaults for default, ephemeral, and background configurations. A background session does not support the same custom protocol testing hook discussed below. If the requirement is persistent background upload, model it separately from an ephemeral, in-memory browsing flow. Selecting a configuration to make one test easy is not a substitute for matching the production lifecycle.

Question 4: How do you test networking without depending on live internet?

Inject a purpose-built URLSession rather than hard-coding URLSession.shared inside every feature. In a test, create a configuration with a custom URLProtocol subclass in protocolClasses, then construct a session from that configuration. The protocol can return a chosen HTTPURLResponse, body, or URL loading error, and can record how many requests were made. Apple's protocolClasses documentation explains this extension point and explicitly says custom protocols cannot be used with background sessions.

Keep test state isolated. A process-global “next response” variable can make tests race when they run concurrently; bind responses to request URLs or give each test its own synchronized fixture. Have the stub respond exactly once, model cancellation through its stop-loading path, and avoid sleeping to wait for a callback. Use expectations or async test APIs to observe completion. The test should verify the client result, not merely that the stub was invoked.

Build a small matrix. For 200, assert the returned bytes or decoded model. For 404, assert the typed status and retain a bounded body. For a simulated network error, assert the failure is not converted into an HTTP status. For cancellation, assert the owner does not update stale UI and that the request is not retried. For caching, assert version and request count under a controlled cache policy. A separate integration test with a local server can cover HTTP behavior that a custom protocol fixture does not faithfully emulate. Do not call a stubbed unit test proof of every background-session or network-stack behavior.

Question 5: What retry rule is safe enough to explain?

Start with the endpoint's semantics. A GET may be retried when a transient transport failure or selected 5xx response occurs, but bound attempts and total elapsed time. Respect a 429 response's guidance where applicable, with a maximum delay the caller can tolerate. Do not retry 404 blindly. Do not retry cancellation. For mutating requests, ask for an idempotency key or a way to reconcile an unknown outcome before any automatic retry. State what the UI displays when all attempts fail.

One possible policy for this toy GET is at most three attempts with bounded backoff and jitter, subject to a screen-level deadline. That number is a proposed design parameter, not an Apple default or a universally correct interview answer. The important invariant is that each attempt has a reason, the operation remains safe to repeat, and total time is bounded. Capture status code, transport error category, attempt count, and whether the response came from cache in structured diagnostics without logging credentials or personal data. The final error should preserve enough information for the caller to distinguish not-found, offline, cancelled, and server failure states.

Sources and Further Reading