Skip to content

Commit 1701d3d

Browse files
authored
Combine headers + trailers into ConnectError.metadata (#239)
As described by @jhump: > Currently, users may need to check both headers and trailers when looking for metadata in an error response for an operation with a unary reply (unary and client-stream RPCs). The reason they may have to look in two places is because where the metadata shows up isn't straight-forward - it depends on if the protocol being used and, if gRPC or gRPC-Web, whether the server used a trailers-only response or not. In a trailers-only response, all metadata would show up in the same bag of metadata (which are technically HTTP response headers, but may be classified as "trailers" since they include the status and there will be no subsequent HTTP response trailers in the reply). This PR updates `ConnectError`'s initialization to combine both headers + trailers into its `metadata` so that it's easier for consumers to query for the keys they need. This also matches connect-go and connect-es.
1 parent 7350355 commit 1701d3d

File tree

9 files changed

+111
-56
lines changed

9 files changed

+111
-56
lines changed

Libraries/Connect/Internal/Interceptors/ConnectInterceptor.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,8 @@ extension ConnectInterceptor: StreamInterceptor {
176176
code: code,
177177
error: ConnectError.from(
178178
code: code,
179-
headers: self.streamResponseHeaders.value ?? [:],
179+
headers: self.streamResponseHeaders.value,
180+
trailers: trailers,
180181
source: nil
181182
),
182183
trailers: trailers

Libraries/Connect/Internal/Interceptors/GRPCWebInterceptor.swift

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,16 @@ extension GRPCWebInterceptor: UnaryInterceptor {
5757
}
5858

5959
guard let responseData = response.message, !responseData.isEmpty else {
60-
let code = response.headers.grpcStatus() ?? response.code
60+
let (grpcCode, connectError) = ConnectError.parseGRPCHeaders(
61+
response.headers,
62+
trailers: response.trailers
63+
)
6164
proceed(HTTPResponse(
62-
code: code,
65+
code: grpcCode,
6366
headers: response.headers,
6467
message: response.message,
6568
trailers: response.trailers,
66-
error: ConnectError.fromGRPCTrailers(response.headers, code: code),
69+
error: connectError,
6770
tracingInfo: response.tracingInfo
6871
))
6972
return
@@ -147,7 +150,7 @@ extension GRPCWebInterceptor: StreamInterceptor {
147150
// Headers-only response.
148151
proceed(.complete(
149152
code: grpcCode,
150-
error: ConnectError.fromGRPCTrailers(headers, code: grpcCode),
153+
error: ConnectError.parseGRPCHeaders(nil, trailers: headers).error,
151154
trailers: headers
152155
))
153156
} else {
@@ -166,13 +169,15 @@ extension GRPCWebInterceptor: StreamInterceptor {
166169
let isTrailers = 0b10000000 & headerByte != 0
167170
if isTrailers {
168171
let trailers = try Trailers.fromGRPCHeadersBlock(unpackedData)
169-
let grpcCode = trailers.grpcStatus() ?? .unknown
172+
let (grpcCode, error) = ConnectError.parseGRPCHeaders(
173+
self.streamResponseHeaders.value, trailers: trailers
174+
)
170175
if grpcCode == .ok {
171176
proceed(.complete(code: .ok, error: nil, trailers: trailers))
172177
} else {
173178
proceed(.complete(
174179
code: grpcCode,
175-
error: ConnectError.fromGRPCTrailers(trailers, code: grpcCode),
180+
error: error,
176181
trailers: trailers
177182
))
178183
}
@@ -222,10 +227,10 @@ private extension Trailers {
222227

223228
private extension HTTPResponse {
224229
func withHandledGRPCWebTrailers(_ trailers: Trailers, message: Data?) -> Self {
225-
let grpcStatus = trailers.grpcStatus() ?? .unknown
226-
if grpcStatus == .ok {
230+
let (grpcCode, error) = ConnectError.parseGRPCHeaders(self.headers, trailers: trailers)
231+
if grpcCode == .ok {
227232
return HTTPResponse(
228-
code: grpcStatus,
233+
code: grpcCode,
229234
headers: self.headers,
230235
message: message,
231236
trailers: trailers,
@@ -234,11 +239,11 @@ private extension HTTPResponse {
234239
)
235240
} else {
236241
return HTTPResponse(
237-
code: grpcStatus,
242+
code: grpcCode,
238243
headers: self.headers,
239244
message: message,
240245
trailers: trailers,
241-
error: ConnectError.fromGRPCTrailers(trailers, code: grpcStatus),
246+
error: error,
242247
tracingInfo: self.tracingInfo
243248
)
244249
}

Libraries/Connect/PackageInternal/ConnectError+GRPC.swift

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,24 +18,39 @@ extension ConnectError {
1818
/// This should not be considered part of Connect's public/stable interface, and is subject
1919
/// to change. When the compiler supports it, this should be package-internal.
2020
///
21-
/// Creates an error using gRPC trailers.
21+
/// Parses gRPC headers and/or trailers to obtain the status and any potential error.
2222
///
23-
/// - parameter trailers: The trailers (or headers, for gRPC-Web) from which to parse the error.
24-
/// - parameter code: The status code received from the server.
23+
/// - parameter headers: Headers received from the server.
24+
/// - parameter trailers: Trailers received from the server. Note that this could be trailers
25+
/// passed in the headers block for gRPC-Web.
2526
///
26-
/// - returns: An error, if the status indicated an error.
27-
public static func fromGRPCTrailers(_ trailers: Trailers, code: Code) -> Self? {
28-
if code == .ok {
29-
return nil
27+
/// - returns: A tuple containing the gRPC status code and an optional error.
28+
public static func parseGRPCHeaders(
29+
_ headers: Headers?, trailers: Trailers?
30+
) -> (grpcCode: Code, error: ConnectError?) {
31+
// "Trailers-only" responses can be sent in the headers or trailers block.
32+
// Check for a valid gRPC status in the headers first, then in the trailers.
33+
guard let grpcCode = headers?.grpcStatus() ?? trailers?.grpcStatus() else {
34+
return (.unknown, ConnectError(
35+
code: .unknown, message: "RPC response missing status", exception: nil,
36+
details: [], metadata: [:]
37+
))
3038
}
3139

32-
return .init(
33-
code: code,
34-
message: trailers.grpcMessage(),
40+
if grpcCode == .ok {
41+
return (.ok, nil)
42+
}
43+
44+
// Combine headers + trailers into metadata to make error parsing easier for consumers,
45+
// since gRPC can include error information in either headers or trailers.
46+
let metadata = (headers ?? [:]).merging(trailers ?? [:]) { $1 }
47+
return (grpcCode, .init(
48+
code: grpcCode,
49+
message: metadata.grpcMessage(),
3550
exception: nil,
36-
details: trailers.connectErrorDetailsFromGRPC(),
37-
metadata: trailers
38-
)
51+
details: metadata.connectErrorDetailsFromGRPC(),
52+
metadata: metadata
53+
))
3954
}
4055
}
4156

Libraries/Connect/Public/Implementation/Clients/ProtocolClient.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,7 @@ private extension ResponseMessage where Output: ProtobufMessage {
428428
?? ConnectError.from(
429429
code: response.code,
430430
headers: response.headers,
431+
trailers: response.trailers,
431432
source: response.message
432433
)
433434
self.init(

Libraries/Connect/Public/Interfaces/ConnectError.swift

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -110,26 +110,34 @@ extension ConnectError: Swift.Decodable {
110110
}
111111

112112
extension ConnectError {
113-
public static func from(code: Code, headers: Headers, source: Data?) -> Self {
114-
let headers = headers.reduce(into: Headers(), { headers, current in
115-
headers[current.key.lowercased()] = current.value
116-
})
113+
public static func from(
114+
code: Code, headers: Headers?, trailers: Trailers?, source: Data?
115+
) -> Self {
116+
// Combine headers + trailers into metadata to make error parsing easier for consumers,
117+
// since gRPC can include error information in either headers or trailers.
118+
var metadata = Headers()
119+
for (headerName, headerValue) in headers ?? [:] {
120+
metadata[headerName.lowercased()] = headerValue
121+
}
122+
for (trailerName, trailerValue) in trailers ?? [:] {
123+
metadata[trailerName.lowercased()] = trailerValue
124+
}
117125

118126
guard let source = source else {
119127
return .init(
120128
code: code, message: "empty error message from source", exception: nil,
121-
details: [], metadata: headers
129+
details: [], metadata: metadata
122130
)
123131
}
124132

125133
do {
126134
var connectError = try Foundation.JSONDecoder().decode(ConnectError.self, from: source)
127-
connectError.metadata = headers
135+
connectError.metadata = metadata
128136
return connectError
129137
} catch let error {
130138
return .init(
131139
code: code, message: String(data: source, encoding: .utf8),
132-
exception: error, details: [], metadata: headers
140+
exception: error, details: [], metadata: metadata
133141
)
134142
}
135143
}

Libraries/ConnectNIO/Internal/GRPCInterceptor.swift

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,9 @@ extension GRPCInterceptor: UnaryInterceptor {
5959
return
6060
}
6161

62-
let (grpcCode, connectError) = self.grpcResult(
63-
fromHeaders: response.headers, trailers: response.trailers
62+
let (grpcCode, connectError) = ConnectError.parseGRPCHeaders(
63+
response.headers,
64+
trailers: response.trailers
6465
)
6566
guard grpcCode == .ok, let rawData = response.message, !rawData.isEmpty else {
6667
proceed(HTTPResponse(
@@ -154,8 +155,9 @@ extension GRPCInterceptor: StreamInterceptor {
154155
return
155156
}
156157

157-
let (grpcCode, connectError) = self.grpcResult(
158-
fromHeaders: self.streamResponseHeaders.value, trailers: trailers
158+
let (grpcCode, connectError) = ConnectError.parseGRPCHeaders(
159+
self.streamResponseHeaders.value,
160+
trailers: trailers
159161
)
160162
if grpcCode == .ok {
161163
proceed(.complete(
@@ -172,20 +174,6 @@ extension GRPCInterceptor: StreamInterceptor {
172174
}
173175
}
174176
}
175-
176-
private func grpcResult(
177-
fromHeaders headers: Headers?, trailers: Trailers?
178-
) -> (code: Code, error: ConnectError?) {
179-
// "Trailers-only" responses can be sent in the headers or trailers block.
180-
// Check for a valid gRPC status in the headers first, then in the trailers.
181-
if let headers = headers, let grpcCode = headers.grpcStatus() {
182-
return (grpcCode, .fromGRPCTrailers(headers, code: grpcCode))
183-
} else if let trailers = trailers, let grpcCode = trailers.grpcStatus() {
184-
return (grpcCode, .fromGRPCTrailers(trailers, code: grpcCode))
185-
} else {
186-
return (.unknown, nil)
187-
}
188-
}
189177
}
190178

191179
private final class Locked<T>: @unchecked Sendable {

Tests/UnitTests/ConnectLibraryTests/ConnectTests/ConnectErrorTests.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ final class ConnectErrorTests: XCTestCase {
5454
XCTAssertEqual(error.unpackedDetails(), [expectedDetails1, expectedDetails2])
5555
XCTAssertTrue(error.metadata.isEmpty)
5656
}
57+
5758
func testDeserializingErrorUsingHelperFunctionLowercasesHeaderKeys() throws {
5859
let expectedDetails = Connectrpc_Conformance_V1_RawHTTPRequest.with { $0.uri = "a/b/c" }
5960
let errorData = try self.errorData(expectedDetails: [expectedDetails])
@@ -63,6 +64,7 @@ final class ConnectErrorTests: XCTestCase {
6364
"sOmEkEy": ["foo"],
6465
"otherKey1": ["BAR", "bAz"],
6566
],
67+
trailers: nil,
6668
source: errorData
6769
)
6870
XCTAssertEqual(error.code, .unavailable) // Respects the code from the error body
@@ -73,6 +75,33 @@ final class ConnectErrorTests: XCTestCase {
7375
XCTAssertEqual(error.metadata, ["somekey": ["foo"], "otherkey1": ["BAR", "bAz"]])
7476
}
7577

78+
func testDeserializingErrorUsingHelperFunctionCombinesHeadersAndTrailers() throws {
79+
let expectedDetails = Connectrpc_Conformance_V1_RawHTTPRequest.with { $0.uri = "a/b/c" }
80+
let errorData = try self.errorData(expectedDetails: [expectedDetails])
81+
let error = ConnectError.from(
82+
code: .aborted,
83+
headers: [
84+
"duPlIcaTedKey": ["headers"],
85+
"otherKey1": ["BAR", "bAz"],
86+
],
87+
trailers: [
88+
"duPlIcaTedKey": ["trailers"],
89+
"anOthErKey": ["foo"],
90+
],
91+
source: errorData
92+
)
93+
XCTAssertEqual(error.code, .unavailable) // Respects the code from the error body
94+
XCTAssertEqual(error.message, "overloaded: back off and retry")
95+
XCTAssertNil(error.exception)
96+
XCTAssertEqual(error.details.count, 1)
97+
XCTAssertEqual(error.unpackedDetails(), [expectedDetails])
98+
XCTAssertEqual(error.metadata, [
99+
"duplicatedkey": ["trailers"],
100+
"otherkey1": ["BAR", "bAz"],
101+
"anotherkey": ["foo"],
102+
])
103+
}
104+
76105
func testDeserializingSimpleError() throws {
77106
let errorDictionary = [
78107
"code": "unavailable",

Tests/UnitTests/ConnectLibraryTests/ConnectTests/InterceptorChainIterationTests.swift

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ final class InterceptorChainIterationTests: XCTestCase {
104104
chain.executeInterceptorsAndStopOnFailure(
105105
[
106106
{ _, proceed in
107-
proceed(.failure(.from(code: .unknown, headers: Headers(), source: nil)))
107+
proceed(.failure(.from(
108+
code: .unknown, headers: nil, trailers: nil, source: nil
109+
)))
108110
},
109111
{ value, proceed in proceed(.success(value + "b")) },
110112
],
@@ -141,7 +143,9 @@ final class InterceptorChainIterationTests: XCTestCase {
141143
chain.executeLinkedInterceptorsAndStopOnFailure(
142144
[
143145
{ _, proceed in
144-
proceed(.failure(.from(code: .unknown, headers: Headers(), source: nil)))
146+
proceed(.failure(.from(
147+
code: .unknown, headers: nil, trailers: nil, source: nil
148+
)))
145149
},
146150
{ value, proceed in proceed(.success(value + "2")) },
147151
],
@@ -168,7 +172,9 @@ final class InterceptorChainIterationTests: XCTestCase {
168172
firstInFirstOut: true,
169173
initial: "",
170174
transform: { _, proceed in
171-
proceed(.failure(.from(code: .unknown, headers: Headers(), source: nil)))
175+
proceed(.failure(.from(
176+
code: .unknown, headers: nil, trailers: nil, source: nil
177+
)))
172178
},
173179
then: [
174180
{ value, proceed in proceed(.success(value + 1)) },
@@ -192,7 +198,9 @@ final class InterceptorChainIterationTests: XCTestCase {
192198
transform: { value1, proceed in proceed(.success(Int(value1)!)) },
193199
then: [
194200
{ _, proceed in
195-
proceed(.failure(.from(code: .unknown, headers: Headers(), source: nil)))
201+
proceed(.failure(.from(
202+
code: .unknown, headers: nil, trailers: nil, source: nil
203+
)))
196204
},
197205
{ value, proceed in proceed(.success(value + 3)) },
198206
],

Tests/UnitTests/ConnectLibraryTests/ConnectTests/InterceptorIntegrationTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ extension StepTrackingInterceptor: UnaryInterceptor {
374374
) {
375375
self.trackStep(.unaryRequest(id: self.id))
376376
if self.failOutboundRequests {
377-
proceed(.failure(.from(code: .aborted, headers: Headers(), source: nil)))
377+
proceed(.failure(.from(code: .aborted, headers: nil, trailers: nil, source: nil)))
378378
} else if self.requestDelay != .never {
379379
DispatchQueue.global().asyncAfter(deadline: .now() + self.requestDelay) {
380380
proceed(.success(request))
@@ -420,7 +420,7 @@ extension StepTrackingInterceptor: StreamInterceptor {
420420
) {
421421
self.trackStep(.streamStart(id: self.id))
422422
if self.failOutboundRequests {
423-
proceed(.failure(.from(code: .aborted, headers: Headers(), source: nil)))
423+
proceed(.failure(.from(code: .aborted, headers: nil, trailers: nil, source: nil)))
424424
} else if self.requestDelay != .never {
425425
DispatchQueue.global().asyncAfter(deadline: .now() + self.requestDelay) {
426426
proceed(.success(request))

0 commit comments

Comments
 (0)