Are HTTP header names case-sensitive in URLSession, and how do you read them safely?
Predict first
Which lookups find the ETag header?
import Foundation
let r = HTTPURLResponse(url: URL(string: "https://x.test")!, statusCode: 200, httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json", "ETag": "\"abc\"", "x-request-id": "42"])!
print(r.value(forHTTPHeaderField: "content-type") ?? "nil", r.value(forHTTPHeaderField: "ETAG") ?? "nil")
print(r.allHeaderFields["ETag"] ?? "nil", r.allHeaderFields["etag"] ?? "nil")
print((r.allHeaderFields as? [String: String])?.keys.sorted() ?? [])
- value(forHTTPHeaderField:) finds both; allHeaderFields finds neither "ETag" nor "etag" (key is Etag)
- All four lookups find it
- Only allHeaderFields["ETag"] finds it
Mental model. Header names are like email addresses: the person receiving them does not care about capital letters, but a hand-written address book that does will fail to find them.
HTTP header names are case-insensitive by the protocol (and HTTP/2 and HTTP/3 send them in lowercase), so a server may answer with ETag, Etag or etag. In Foundation, HTTPURLResponse.value(forHTTPHeaderField:) does the right thing: both "content-type" and "ETAG" find their headers. But allHeaderFields is an ordinary dictionary with case-sensitive keys, and on current systems the keys are normalised: in the check the key is Etag, so both allHeaderFields["ETag"] and allHeaderFields["etag"] are nil even though the header is present. Code that indexes the dictionary directly works in a stubbed test (where you supplied the casing) and fails against a real server.
Read headers with value(forHTTPHeaderField:), and on requests use setValue(_:forHTTPHeaderField:), which also treats the name case-insensitively. Normalise yourself if you must iterate (lowercase the keys once). Beware of headers that are special to the system: Content-Length, Authorization and Cookie may be managed or overwritten by the loading system (Authorization can be dropped on cross-origin redirects), and a request header you set in a stub may not be what the wire sees. For tests, build HTTPURLResponse objects with realistic header casing, and assert through value(forHTTPHeaderField:) so tests and production behave alike.
value(forHTTPHeaderField:) is case-insensitive; the dictionary has normalised, case-sensitive keys.
Common traps
- Subscripting
allHeaderFieldswith a literal name. The stored casing may differ, so the lookup returnsnil. - Writing tests that pass because the stub’s casing matches your code. A real server may use another.
They might follow up with
- How do I get all values of a repeated header? Foundation joins repeated headers with a comma into one string; split carefully (cookies are an exception).
- Which headers should an API client set once? On the session configuration (
httpAdditionalHeaders) for constants, per request for the rest.
Related questions
- 12.8.1 What goes wrong when you build a URL with string concatenation, and how do URLComponents, percent-encoding…
- 12.8.4 What do timeoutIntervalForRequest, timeoutIntervalForResource and the per-request timeout measure, and how do…
- 12.3.2 How does HTTP caching work with Cache-Control, ETag and conditional requests, and how does URLCache fit in?
← What goes wrong when you build a URL with string concatenation, and… How do you classify what URLSession reports: which failures are… →