Are HTTP header names case-sensitive in URLSession, and how do you read them safely?

12.8.2 · L2 · Learning

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 allHeaderFields with a literal name. The stored casing may differ, so the lookup returns nil.
  • 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… →