“Something went wrong. Please try again.” is what an app says when its networking layer has collapsed every failure into one case. Sometimes trying again is exactly right. Sometimes the user is offline and trying again will fail the same way until they are not. Sometimes the server rejected the request because a field was invalid, and trying again will fail forever. And sometimes the request was cancelled because the user left the screen, and nothing should be shown at all.
A network request can fail at many layers, and each layer means something different to the user and to the code that decides whether to retry. This article splits failures into two families. Transport failures mean no usable HTTP response arrived. Application failures mean a response arrived and said no, or said something the client cannot use. It produces each failure for real against a local server, records the exact error it causes, and builds an error type that carries the distinctions the rest of the app needs.
The examples use Swift 6 language mode and were compiled and run with the Swift 6.4 toolchain from Xcode 27.1 on macOS 26. They send requests to LocalServer, the loopback HTTP server from the appendix of the first article; paste that code first, then the blocks below in order, into one main.swift.
A request crosses many layers
Between try await session.data(for:) and a value on screen, a request passes through a stack of layers, and each one can fail on its own terms:
flowchart TB
accTitle: The layers a request crosses, and the family of failure each one produces
subgraph Transport["Transport: no usable HTTP response (URLError thrown)"]
A[Name resolution: DNS] --> B[Connection: TCP or QUIC]
B --> C[Security: TLS and trust]
C --> D[HTTP framing: status line, headers, body length, encoding]
end
subgraph Application["Application: a response arrived (returned, not thrown)"]
E[Status code: 2xx, 4xx, 5xx] --> F[Media type: is this JSON?]
F --> G[Decoding: does it match the model?]
G --> H[Domain rules: is the value usable?]
end
D --> E
The boundary between the families is also the boundary in the API. URLSession throws for everything in the transport family, as a URLError. It returns for everything in the application family: a 404 or a 503 is a successfully delivered HTTP response, and so is a 200 whose body is an HTML login page.
Transport failures, measured
The server below has a route for each way an HTTP exchange can break below the application. A second server is started and then stopped, so that its port refuses connections:
import Foundation
let server = try await LocalServer.start { request in
switch request.path {
case "/hang":
HTTPResponse(status: 200, delay: .seconds(30))
case "/drop":
.drop
case "/truncated":
HTTPResponse(status: 200, headers: [("Content-Length", "10")], body: [.bytes(Data("abc".utf8)), .close])
case "/bad-chunk":
HTTPResponse(status: 200, headers: [("Transfer-Encoding", "chunked"), ("Content-Length", "0")],
body: [.bytes(Data("zz\r\nhello\r\n".utf8))])
case "/false-gzip":
HTTPResponse(status: 200, headers: [("Content-Encoding", "gzip")], body: [.bytes(Data("plain text".utf8))])
case "/bad-status":
HTTPResponse(status: 1000, body: [.bytes(Data("x".utf8))])
default:
.text("ok")
}
}
let gone = try await LocalServer.start { _ in .text("") }
let refusedURL = gone.baseURL
gone.stop()
try await Task.sleep(for: .milliseconds(100))
func failure(of url: URL, timeout: TimeInterval = 60) async -> URLError? {
var request = URLRequest(url: url)
request.timeoutInterval = timeout
do {
_ = try await URLSession.shared.data(for: request)
return nil
} catch {
return error as? URLError
}
}
let refused = await failure(of: refusedURL)
let unknownHost = await failure(of: URL(string: "https://library.invalid/books")!)
let timedOut = await failure(of: server.baseURL.appending(path: "hang"), timeout: 1)
let dropped = await failure(of: server.baseURL.appending(path: "drop"))
let truncated = await failure(of: server.baseURL.appending(path: "truncated"))
let badChunk = await failure(of: server.baseURL.appending(path: "bad-chunk"))
let falseGzip = await failure(of: server.baseURL.appending(path: "false-gzip"))
let badStatus = await failure(of: server.baseURL.appending(path: "bad-status"), timeout: 1)
let unsupported = await failure(of: URL(string: "mailto:[email protected]")!)
assert(refused?.code == .cannotConnectToHost) // -1004
assert(unknownHost?.code == .cannotFindHost) // -1003
assert(timedOut?.code == .timedOut) // -1001
assert(dropped?.code == .networkConnectionLost) // -1005
assert(truncated?.code == .networkConnectionLost) // -1005
assert(badChunk?.code == .cannotParseResponse) // -1017
assert(falseGzip?.code == .cannotDecodeRawData) // -1015
assert(badStatus?.code == .timedOut) // -1001, see below
assert(unsupported?.code == .unsupportedURL) // -1002
| Cause | URLError.Code |
Time to fail (measured) |
|---|---|---|
| Nothing listening on the port | cannotConnectToHost (-1004) |
28 ms |
| DNS name does not exist | cannotFindHost (-1003) |
108 ms |
| Server accepts, never answers | timedOut (-1001) |
the request timeout |
| Connection closed before a response | networkConnectionLost (-1005) |
6 ms (after 3 attempts for GET) |
Body shorter than Content-Length |
networkConnectionLost (-1005) |
1 ms |
| Invalid chunk size | cannotParseResponse (-1017) |
6 ms |
Content-Encoding: gzip on plain text |
cannotDecodeRawData (-1015) |
1 ms |
| Four-digit status code | timedOut (-1001) |
the request timeout |
Scheme URLSession does not handle |
unsupportedURL (-1002) |
under 1 ms |
Two rows deserve a comment. A malformed status line does not produce a parse error: the response is never recognized as complete, so the task waits until its timeout, 60 seconds by default. And not every protocol violation fails. A Content-Length of abc was accepted, and the client received a 200 with an empty body. The transport layer is lenient in places, which is one reason the application layer has to check what it received.
Other codes you will see in production but cannot easily produce on a development machine: notConnectedToInternet (-1009) when no interface is up, dataNotAllowed (-1020) when cellular data is off for the app, internationalRoamingOff (-1018), secureConnectionFailed (-1200) and the serverCertificate… family (-1201 to -1206) for TLS failures, which App Transport Security and TLS Trust produces for real, and appTransportSecurityRequiresSecureConnection (-1022) when ATS blocks plain HTTP.
What a URLError carries
A URLError is more than its code. It records the URL that failed, an underlying CFNetwork error, and for socket-level failures the stream error domain and code, which for the refused connection is POSIX error 61, ECONNREFUSED:
let detail = refused!
assert(detail.failingURL?.port == refusedURL.port) // the URL as sent: "http://127.0.0.1:port/"
assert((detail.userInfo[NSUnderlyingErrorKey] as? NSError)?.domain == "kCFErrorDomainCFNetwork")
assert(detail.userInfo["_kCFStreamErrorDomainKey"] as? Int == 1) // 1 is the POSIX domain
assert(detail.userInfo["_kCFStreamErrorCodeKey"] as? Int == 61) // ECONNREFUSED
assert(detail.networkUnavailableReason == nil)
The underscored keys are not public API and are for logs, not logic. networkUnavailableReason is public: when a request fails because the session forbids cellular, expensive, or constrained networks, it is .cellular, .expensive, or .constrained, which is how the UI can say “Downloads are paused in Low Data Mode” instead of “No connection.”
Application failures arrive as responses
When a response arrives, the transport worked. What remains is deciding whether the response is something the caller can use, and if not, why not. There are three distinct ways it can fail:
- The status says no. A
4xxmeans the request was wrong for the server’s current state; a5xxmeans the server failed to handle a valid request. - The body is not what was asked for. The media type is wrong (an HTML error page from a proxy), or the body does not decode into the expected model.
- The value is unusable. It decoded, but violates a rule the app depends on. Validating Decoded Data Before It Reaches the UI covers this layer.
Servers that follow RFC 9457, Problem Details for HTTP APIs, describe the first kind in a standard JSON shape with the media type application/problem+json. The type is a URI that identifies the problem class, title is a short human-readable summary of it, status repeats the HTTP status, detail explains this occurrence, and any further members are problem-specific extensions:
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{
"type": "https://library.example/problems/loan-limit",
"title": "Loan limit reached",
"status": 403,
"detail": "You have 5 books on loan; the limit is 5.",
"limit": 5
}
Decoding the standard members and keeping the extensions as raw JSON costs a few lines, and it gives the app a stable, machine-readable type to branch on instead of matching English strings:
struct Problem: Decodable, Equatable, Sendable {
var type: URL
var title: String?
var status: Int?
var detail: String?
var extensions: [String: String] = [:]
private struct Key: CodingKey {
var stringValue: String
var intValue: Int? { nil }
init(stringValue: String) { self.stringValue = stringValue }
init?(intValue: Int) { nil }
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: Key.self)
// RFC 9457: a missing "type" means "about:blank".
type = try container.decodeIfPresent(URL.self, forKey: Key(stringValue: "type")) ?? URL(string: "about:blank")!
title = try container.decodeIfPresent(String.self, forKey: Key(stringValue: "title"))
status = try container.decodeIfPresent(Int.self, forKey: Key(stringValue: "status"))
detail = try container.decodeIfPresent(String.self, forKey: Key(stringValue: "detail"))
for key in container.allKeys where !["type", "title", "status", "detail", "instance"].contains(key.stringValue) {
if let text = try? container.decode(String.self, forKey: key) {
extensions[key.stringValue] = text
} else if let number = try? container.decode(Int.self, forKey: key) {
extensions[key.stringValue] = String(number)
}
}
}
}
Many APIs do not use RFC 9457, and some still use a proprietary error envelope. The pattern is the same: decode the error body when the status says failure and the media type matches, and keep it as optional detail rather than assuming it exists. A 502 from a load balancer will not have your API’s error format, however carefully the API defines it.
One error type with the distinctions that matter
The consumers of a networking error need answers to three questions: should anything be shown, can retrying help, and what should the user be told. The error type below is shaped around those questions rather than around the API that produced the failure:
enum APIError: Error, Sendable {
// Transport family: no usable HTTP response
case cancelled
case offline(URLError)
case unreachable(URLError)
case timedOut(URLError)
case insecure(URLError)
case transport(URLError)
// Application family: a response arrived
case http(status: Int, problem: Problem?)
case unexpectedContentType(String?)
case decoding(DecodingError)
init(_ error: URLError) {
switch error.code {
case .cancelled:
self = .cancelled
case .notConnectedToInternet, .dataNotAllowed, .internationalRoamingOff, .callIsActive:
self = .offline(error)
case .cannotFindHost, .cannotConnectToHost, .dnsLookupFailed:
self = .unreachable(error)
case .timedOut:
self = .timedOut(error)
case .secureConnectionFailed, .serverCertificateHasBadDate, .serverCertificateUntrusted,
.serverCertificateHasUnknownRoot, .serverCertificateNotYetValid,
.clientCertificateRejected, .clientCertificateRequired,
.appTransportSecurityRequiresSecureConnection:
self = .insecure(error)
default:
self = .transport(error)
}
}
}
networkConnectionLost deliberately falls into the generic transport case rather than offline. A lost connection means one connection died, which happens constantly on mobile networks as a device moves between cells or from Wi-Fi to cellular. It does not mean the device is offline, and treating it that way shows a misleading message.
The client wraps data(for:) and turns every outcome into either a decoded value or an APIError. The checks run in the order of the layers:
struct APIClient: Sendable {
var session: URLSession = .shared
var decoder = JSONDecoder()
func send<Value: Decodable>(_ request: URLRequest, as type: Value.Type = Value.self) async throws(APIError) -> Value {
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch let error as URLError {
throw APIError(error)
} catch is CancellationError {
throw .cancelled
} catch {
throw .transport(URLError(.unknown, userInfo: [NSUnderlyingErrorKey: error]))
}
guard let http = response as? HTTPURLResponse else { throw .unexpectedContentType(response.mimeType) }
guard (200..<300).contains(http.statusCode) else {
let problem = http.mimeType == "application/problem+json"
? try? JSONDecoder().decode(Problem.self, from: data) : nil
throw .http(status: http.statusCode, problem: problem)
}
guard http.mimeType == "application/json" else { throw .unexpectedContentType(http.mimeType) }
do {
return try decoder.decode(Value.self, from: data)
} catch let error as DecodingError {
throw .decoding(error)
} catch {
throw .decoding(.dataCorrupted(.init(codingPath: [], debugDescription: "\(error)")))
}
}
}
Typed throws, throws(APIError), means callers receive an APIError in their catch without a cast, and the compiler checks that the function cannot leak any other error type. That is valuable at this one boundary, where the whole point is to translate everything into one vocabulary. Inside the app, plain throws is usually still the better default.
Each failure, classified
A second server returns one application failure per route, so the client’s classification can be checked end to end, together with the transport cases from before:
struct Book: Decodable, Equatable { let id: Int; let title: String }
let api = try await LocalServer.start { request in
switch request.path {
case "/books/7":
.json(#"{"id":7,"title":"Dune"}"#)
case "/books/8":
.json(#"{"id":"eight","title":"Emma"}"#)
case "/portal":
HTTPResponse(status: 200, headers: [("Content-Type", "text/html")], body: [.bytes(Data("<html>".utf8))])
case "/loans":
HTTPResponse(status: 403, headers: [("Content-Type", "application/problem+json")],
body: [.bytes(Data(#"""
{"type":"https://library.example/problems/loan-limit","title":"Loan limit reached",
"status":403,"detail":"You have 5 books on loan; the limit is 5.","limit":5}
"""#.utf8))])
case "/gateway":
HTTPResponse(status: 502, headers: [("Content-Type", "text/html")], body: [.bytes(Data("<h1>Bad Gateway</h1>".utf8))])
default:
.text("not found", status: 404)
}
}
let client = APIClient()
func outcome(_ url: URL, timeout: TimeInterval = 5) async -> Result<Book, APIError> {
var request = URLRequest(url: url)
request.timeoutInterval = timeout
do throws(APIError) {
return .success(try await client.send(request, as: Book.self))
} catch {
return .failure(error)
}
}
let found = await outcome(api.baseURL.appending(path: "books/7"))
assert((try? found.get()) == Book(id: 7, title: "Dune"))
guard case .failure(.decoding(.typeMismatch(_, let context))) = await outcome(api.baseURL.appending(path: "books/8")) else {
fatalError("expected a type mismatch")
}
assert(context.codingPath.map(\.stringValue) == ["id"])
guard case .failure(.unexpectedContentType("text/html")) = await outcome(api.baseURL.appending(path: "portal")) else {
fatalError("expected the portal page to be rejected")
}
guard case .failure(.http(403, let problem?)) = await outcome(api.baseURL.appending(path: "loans")) else {
fatalError("expected a problem document")
}
assert(problem.type.absoluteString == "https://library.example/problems/loan-limit")
assert(problem.extensions["limit"] == "5")
guard case .failure(.http(502, nil)) = await outcome(api.baseURL.appending(path: "gateway")) else {
fatalError("a gateway page has no problem document")
}
guard case .failure(.http(404, nil)) = await outcome(api.baseURL.appending(path: "books/999")) else {
fatalError("expected 404")
}
guard case .failure(.unreachable) = await outcome(refusedURL) else { fatalError("expected unreachable") }
guard case .failure(.timedOut) = await outcome(server.baseURL.appending(path: "hang"), timeout: 1) else {
fatalError("expected a timeout")
}
guard case .failure(.transport(let lost)) = await outcome(server.baseURL.appending(path: "drop")),
lost.code == .networkConnectionLost else { fatalError("expected a lost connection") }
The decoding failure keeps the DecodingError and its coding path, ["id"]. That one detail turns a support ticket that says “the app shows an error on some books” into a log line that says “id was a string in the response for book 8.” The Codable article has more on reading decoding errors.
Turning the type into decisions
With the distinctions in place, the decisions become properties. Retryable means retrying the same request, after a delay, might succeed:
extension APIError {
var isRetryable: Bool {
switch self {
case .cancelled, .insecure, .decoding, .unexpectedContentType:
false
case .offline, .unreachable, .timedOut, .transport:
true
case .http(let status, _):
status == 408 || status == 429 || status == 502 || status == 503 || status == 504
}
}
/// Whether the server might have acted on a request whose response was lost.
var outcomeIsUnknown: Bool {
switch self {
case .timedOut: true
case .transport(let error): error.code == .networkConnectionLost
default: false
}
}
}
assert(APIError.http(status: 503, problem: nil).isRetryable)
assert(!APIError.http(status: 400, problem: nil).isRetryable)
assert(!APIError.http(status: 500, problem: nil).isRetryable)
assert(APIError(URLError(.networkConnectionLost)).outcomeIsUnknown)
assert(!APIError(URLError(.cannotConnectToHost)).outcomeIsUnknown)
A 500 is not on the retryable list. It usually means a bug in the server’s handling of this request, and the same request is likely to hit the same bug. 502, 503, and 504 describe a gateway or a service that is temporarily unavailable, which is the case retries exist for. The retry article combines isRetryable with backoff and the request’s idempotency.
outcomeIsUnknown captures the most dangerous ambiguity in client networking. When a POST times out or loses its connection after the request was sent, the client cannot know whether the server processed it. “Payment failed” is then a lie as often as it is the truth. The honest UI says “We couldn’t confirm your payment” and checks the order’s status before offering to pay again. A refused connection, by contrast, means the request never reached the server, so it is safe to say that nothing happened.
Presentation is the second decision. Each case maps to one of a small number of things the UI can do:
| Case | Show the user | Offer |
|---|---|---|
cancelled |
Nothing | Nothing |
offline |
“You’re offline” (with networkUnavailableReason when present) |
Retry when connectivity returns |
unreachable, timedOut, transport |
“Couldn’t reach the library” | Retry |
insecure |
“A secure connection couldn’t be established” | Nothing; never offer to bypass |
http(401, _) |
Nothing yet; refresh credentials first | Sign in, if refresh fails |
http(4xx, problem) |
The problem’s title or a message for its type |
Fix the input |
http(5xx, _) |
“The library is having trouble” | Retry later |
unexpectedContentType, decoding |
“Something went wrong on our side” | Report; log details |
The last row is the one teams most often get wrong in the other direction: a decoding failure is a contract violation between client and server, not a user error and not a network problem. It belongs in your error reporting with the coding path, and it is a reason to read Verifying Contracts Against a Controlled Server.
flowchart TD
accTitle: Classifying the outcome of one request
S[await session.data] -->|throws URLError| T{code}
T -->|cancelled| C[cancelled: show nothing]
T -->|notConnectedToInternet, dataNotAllowed| O[offline]
T -->|cannotFindHost, cannotConnectToHost| U[unreachable]
T -->|timedOut| TO[timedOut: outcome unknown]
T -->|TLS and ATS codes| I[insecure]
T -->|everything else| TR[transport]
S -->|returns response| H{status 2xx?}
H -->|no| P[http status, problem document if any]
H -->|yes| M{mimeType is JSON?}
M -->|no| X[unexpectedContentType]
M -->|yes| D{decodes?}
D -->|no| DE[decoding with coding path]
D -->|yes| V[value]
A note on 200 with an error inside
Some APIs report failures inside a successful response: 200 OK with {"success": false, "error": "LOAN_LIMIT"}. GraphQL does this by design, returning 200 with an errors array for many failures. The client cannot use HTTP status to classify these, so the decoding layer has to. Model the envelope explicitly, decode it before the payload, and translate its error into the same APIError.http-like case the rest of the app understands. Do not let it reach the UI as a successful response with missing fields. For APIs you design, return the status that describes the outcome: caches, proxies, retry logic, and monitoring all rely on it.
Summary
- Transport failures mean no usable HTTP response arrived;
URLSessionthrows them asURLError. Application failures arrive as responses and must be detected by the client. - Measured on macOS 26: a refused port is
cannotConnectToHost(-1004) in about 30 ms, an unknown hostcannotFindHost(-1003), a dropped or truncated responsenetworkConnectionLost(-1005), a bad chunkcannotParseResponse(-1017), a falseContent-EncodingcannotDecodeRawData(-1015). A malformed status line hangs until the timeout. - RFC 9457 problem details give
4xxand5xxresponses a machine-readabletype; decode them when present and never assume they are. - Classify into a small set of cases shaped by what consumers decide: show or not, retry or not, and what to say.
networkConnectionLostis not “offline”. A timeout or lost connection after aPOSTleaves the outcome unknown, and the UI must say so.- A
500usually is not worth retrying;502,503,504,408, and429usually are.
Exercise
Add an .offline reproduction to the tests: create a session with allowsConstrainedNetworkAccess = false and allowsExpensiveNetworkAccess = false and run it on a device in Low Data Mode, or, on a Mac, read the URLError you get with Wi-Fi turned off. Record networkUnavailableReason for each case. Then write a userMessage property on APIError that uses Problem.type for known problem types, falls back to Problem.title, and never shows detail without escaping, since it is server-controlled text. Add assertions for every row of the presentation table.
Further reading
- RFC 9457: Problem Details for HTTP APIs.
- Apple’s URLError.Code reference.
- Handling an Authentication Challenge for where TLS and credential failures originate.
Previous: Requests and Responses with async and await. Next: Building Typed Endpoints and Request Bodies.
Return to the Networking roadmap.