Skip to content

Commit c15f853

Browse files
tikitumattt
andauthored
Make the order of headers deterministic when recording HAR files (#7)
* Add tests showing the nondeterminism I couldn't figure out how to *deterministically* reproduce the issue, so these are stochastic. They fail 100% reliably in my dev environment, but I have no idea whether that will be the case elsewhere. For the record my output of `swift --version` is: swift-driver version: 1.120.5 Apple Swift version 6.1.2 (swiftlang-6.1.2.1.2 clang-1700.0.13.5) Target: arm64-apple-macosx15.0 * Sort headers (request & response) lexicographically before serialising * swift format -i -r . --------- Co-authored-by: Mattt Zmuda <mattt@me.com>
1 parent 2101f8f commit c15f853

2 files changed

Lines changed: 91 additions & 6 deletions

File tree

Sources/Replay/HAR.swift

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -880,9 +880,11 @@ extension HAR.Request {
880880
self.httpVersion = "HTTP/1.1"
881881

882882
// Headers
883-
self.headers = (urlRequest.allHTTPHeaderFields ?? [:]).map { key, value in
884-
HAR.Header(name: key, value: value)
885-
}
883+
self.headers = (urlRequest.allHTTPHeaderFields ?? [:])
884+
.sorted { $0.key < $1.key }
885+
.map { key, value in
886+
HAR.Header(name: key, value: value)
887+
}
886888

887889
// Query string
888890
let components = URLComponents(url: url, resolvingAgainstBaseURL: true)
@@ -945,9 +947,11 @@ extension HAR.Response {
945947
self.statusText = HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode)
946948
self.httpVersion = "HTTP/1.1"
947949

948-
self.headers = httpResponse.allHeaderFields.map { key, value in
949-
HAR.Header(name: String(describing: key), value: String(describing: value))
950-
}
950+
self.headers = httpResponse.allHeaderFields
951+
.sorted { String(describing: $0.key) < String(describing: $1.key) }
952+
.map { key, value in
953+
HAR.Header(name: String(describing: key), value: String(describing: value))
954+
}
951955

952956
let mimeType = httpResponse.mimeType ?? "application/octet-stream"
953957
let utf8Text = String(data: data, encoding: .utf8)

Tests/ReplayTests/HARTests.swift

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,42 @@ struct HARTests {
558558
#expect(request.queryString[0].name == "key")
559559
#expect(request.queryString[0].value == "")
560560
}
561+
562+
@Test("headers order is deterministic across multiple conversions")
563+
func headersOrderIsDeterministic() throws {
564+
// Create a URLRequest with multiple headers
565+
// Using enough headers to make nondeterministic ordering likely to be observable
566+
// This did reliably produce failures before adding the explicit sorting
567+
var urlRequest = URLRequest(url: URL(string: "https://example.com/api")!)
568+
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
569+
urlRequest.setValue("gzip, deflate", forHTTPHeaderField: "Accept-Encoding")
570+
urlRequest.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
571+
urlRequest.setValue("Bearer token123", forHTTPHeaderField: "Authorization")
572+
urlRequest.setValue("keep-alive", forHTTPHeaderField: "Connection")
573+
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
574+
urlRequest.setValue("example.com", forHTTPHeaderField: "Host")
575+
urlRequest.setValue("https://example.com", forHTTPHeaderField: "Origin")
576+
urlRequest.setValue("https://example.com/previous", forHTTPHeaderField: "Referer")
577+
urlRequest.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent")
578+
579+
// Convert to HAR.Request multiple times
580+
let iterations = 10
581+
var headerOrders: [[String]] = []
582+
583+
for _ in 0 ..< iterations {
584+
let request = try HAR.Request(from: urlRequest)
585+
let headerNames = request.headers.map { $0.name }
586+
headerOrders.append(headerNames)
587+
}
588+
589+
// Check if all iterations produced the same header order
590+
let firstOrder = headerOrders[0]
591+
let allSame = headerOrders.allSatisfy { $0 == firstOrder }
592+
#expect(
593+
allSame,
594+
"Header order should be consistent across multiple conversions, but got varying orders: \(headerOrders)"
595+
)
596+
}
561597
}
562598

563599
// MARK: - Response Tests
@@ -634,6 +670,51 @@ struct HARTests {
634670
#expect(response.bodySize == data.count)
635671
}
636672

673+
@Test("headers order is deterministic across multiple conversions")
674+
func headersOrderIsDeterministic() throws {
675+
// Create an HTTPURLResponse with multiple headers
676+
// Using enough headers to make nondeterministic ordering likely to be observable
677+
// This did reliably produce failures before adding the explicit sorting
678+
let url = URL(string: "https://example.com/api")!
679+
let data = "{\"test\":true}".data(using: .utf8)!
680+
681+
// Convert to HAR.Response multiple times
682+
let iterations = 10
683+
var headerOrders: [[String]] = []
684+
685+
for _ in 0 ..< iterations {
686+
let httpResponse = HTTPURLResponse(
687+
url: url,
688+
statusCode: 200,
689+
httpVersion: "HTTP/1.1",
690+
headerFields: [
691+
"Content-Type": "application/json",
692+
"Cache-Control": "no-cache, no-store, must-revalidate",
693+
"Content-Length": "12345",
694+
"Date": "Thu, 16 Jan 2026 12:00:00 GMT",
695+
"ETag": "\"abc123\"",
696+
"Expires": "0",
697+
"Last-Modified": "Thu, 16 Jan 2026 11:00:00 GMT",
698+
"Server": "nginx/1.21.0",
699+
"Vary": "Accept-Encoding",
700+
"X-Frame-Options": "SAMEORIGIN",
701+
]
702+
)!
703+
704+
let response = try HAR.Response(from: httpResponse, data: data)
705+
let headerNames = response.headers.map { $0.name }
706+
headerOrders.append(headerNames)
707+
}
708+
709+
// Check if all iterations produced the same header order
710+
let firstOrder = headerOrders[0]
711+
let allSame = headerOrders.allSatisfy { $0 == firstOrder }
712+
#expect(
713+
allSame,
714+
"Header order should be consistent across multiple conversions, but got varying orders: \(headerOrders)"
715+
)
716+
}
717+
637718
@Test("encodes binary data as base64")
638719
func initFromHTTPURLResponseBinary() throws {
639720
let url = URL(string: "https://example.com/image.png")!

0 commit comments

Comments
 (0)