How do you classify what URLSession reports: which failures are URLError, which are not, and which are worth retrying?

12.8.3 · L3 · Interview

Predict first

What does a 503, an offline error, a timeout and a cancelled task each look like?

import Foundation

final class Stub: URLProtocol {
    nonisolated(unsafe) static var handler: (@Sendable (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() {
        do {
            let (resp, data) = try Stub.handler!(request)
            client?.urlProtocol(self, didReceive: resp, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: data)
            client?.urlProtocolDidFinishLoading(self)
        } catch { client?.urlProtocol(self, didFailWithError: error) }
    }
    override func stopLoading() {}
}
let cfg = URLSessionConfiguration.ephemeral
cfg.protocolClasses = [Stub.self]
let session = URLSession(configuration: cfg)
let url = URL(string: "https://api.test/x")!

func attempt(_ name: String, _ error: Error?, status: Int = 200) async {
    Stub.handler = { _ in
        if let e = error { throw e }
        return (HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil)!, Data("ok".utf8))
    }
    do {
        let (_, r) = try await session.data(from: url)
        print(name, "-> no error, status", (r as! HTTPURLResponse).statusCode)
    } catch let e as URLError {
        print(name, "-> URLError", e.code.rawValue)
    } catch { print(name, "-> other") }
}
await attempt("503", nil, status: 503)
await attempt("offline", URLError(.notConnectedToInternet))
await attempt("timeout", URLError(.timedOut))
Stub.handler = { _ in (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data()) }
let t = Task { try await session.data(from: URL(string: "https://api.test/slow")!) }
t.cancel()
do { _ = try await t.value } catch let e as URLError { print("cancelled task -> URLError cancelled:", e.code == .cancelled) } catch { print("other") }
  • 503 -> no error, status 503 / offline -> URLError -1009 / timeout -> URLError -1001 / cancelled task -> URLError cancelled: true
  • 503 -> URLError / offline -> URLError / timeout -> URLError / cancelled task -> other
  • 503 -> no error, status 503 / offline -> no error / timeout -> URLError -1001 / cancelled task -> no error

Mental model. A courier can fail to reach the door (URLError), reach it and be told “no” (a status code), or deliver a parcel that turns out to be empty (decoding). Each needs a different response.

A transport failure is thrown as a URLError with a code; an HTTP error status is not an error at all from URLSession’s point of view (a 503 comes back as a normal response with statusCode == 503, as the check shows), so your client must inspect the status code and map it. That gives three buckets: “the request could not be completed” (URLError: .notConnectedToInternet −1009, .timedOut −1001, .networkConnectionLost, .cannotFindHost, TLS failures), “the server answered with an error” (4xx and 5xx status), and “the answer was unusable” (decoding failed). Keep them as separate cases in your own error type so callers and analytics can tell them apart.

Cancellation is its own case: cancelling a task makes the request fail with URLError.cancelled (−999), which is a deliberate stop and not a failure to show to the user or retry. Retry only what is transient and safe: timeouts and lost connections on idempotent requests, 429 and 503 (honouring Retry-After), and never .cancelled, 4xx client errors, or a POST without an idempotency key (Q324). Offline (.notConnectedToInternet) is better handled by waiting for connectivity (waitsForConnectivity) or by a queue than by a tight retry loop (Q323). Convert errors at one boundary into your domain’s error enum, preserve the underlying error for logging, and never show raw localizedDescription strings from the system as product copy.

Only transport failures throw URLError; an HTTP error status is a normal response.

Common traps

  • Treating URLError.cancelled as a failure. It usually means the user left the screen; logging it as an error is noise.
  • Assuming a 4xx or 5xx throws. It does not; check the status.

They might follow up with

  • How do I tell “no network” from “server down”? .notConnectedToInternet versus a timeout or a 5xx; NWPathMonitor gives context but is not authoritative.
  • What is URLError.userInfo[NSURLErrorFailingURLErrorKey]? The URL that failed, useful in logs without leaking query parameters.

Related questions

  • 12.8.4 What do timeoutIntervalForRequest, timeoutIntervalForResource and the per-request timeout measure, and how do…
  • 12.9.1 Five requests fail with 401 at the same moment. How do you refresh the token once, retry each request once…
  • 12.1.2 How do you make a request with URLSession, and why is a 404 not an error?
  • 12.4.3 Why use NWPathMonitor instead of reachability, and how do you use connectivity information correctly?
  • 12.5.1 How should a client retry failed requests: what to retry, how long to wait, and what idempotency has to do…

← Are HTTP header names case-sensitive in URLSession, and how do you…   What do timeoutIntervalForRequest, timeoutIntervalForResource and the… →