Skip to content

Commit 038c22f

Browse files
authored
Handle non-200 status codes more gracefully (#1613)
Motivation: Accepted RPCs should have a 200 HTTP response status code. gRPC knows how to handle some non-200 status codes and can synthesize a gRPC status from them. At the moment they are treated as errors and later converted to a gRPC status. However this means that only the gRPC status sees the error as a status: other response parts (e.g. the response stream) see an error relating to invalid HTTP response codes rather than the synthesized status. Modifications: - Handle non-200 HTTP status codes more gracefully by turning them into a gRPC status where possible. Results: - Non-200 status codes are more readily converted to a gRPC status.
1 parent 76d4ec1 commit 038c22f

5 files changed

+101
-41
lines changed

Sources/GRPC/GRPCClientChannelHandler.swift

-2
Original file line numberDiff line numberDiff line change
@@ -374,8 +374,6 @@ extension GRPCClientChannelHandler: ChannelInboundHandler {
374374
return GRPCError.InvalidContentType(contentType).captureContext()
375375
case let .invalidHTTPStatus(status):
376376
return GRPCError.InvalidHTTPStatus(status).captureContext()
377-
case let .invalidHTTPStatusWithGRPCStatus(status):
378-
return GRPCError.InvalidHTTPStatusWithGRPCStatus(status).captureContext()
379377
case .invalidState:
380378
return GRPCError.InvalidState("parsing end-of-stream trailers").captureContext()
381379
}

Sources/GRPC/GRPCClientStateMachine.swift

+14-14
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,6 @@ enum ReceiveEndOfResponseStreamError: Error, Equatable {
4141
/// The HTTP response status from the server was not 200 OK.
4242
case invalidHTTPStatus(String?)
4343

44-
/// The HTTP response status from the server was not 200 OK but the "grpc-status" header contained
45-
/// a valid value.
46-
case invalidHTTPStatusWithGRPCStatus(GRPCStatus)
47-
4844
/// An invalid state was encountered. This is a serious implementation error.
4945
case invalidState
5046
}
@@ -742,7 +738,7 @@ extension GRPCClientStateMachine.State {
742738
private func readStatusCode(from trailers: HPACKHeaders) -> GRPCStatus.Code? {
743739
return trailers.first(name: GRPCHeaderName.statusCode)
744740
.flatMap(Int.init)
745-
.flatMap(GRPCStatus.Code.init)
741+
.flatMap({ GRPCStatus.Code(rawValue: $0) })
746742
}
747743

748744
private func readStatusMessage(from trailers: HPACKHeaders) -> String? {
@@ -764,18 +760,22 @@ extension GRPCClientStateMachine.State {
764760
//
765761
// See: https://github.com/grpc/grpc/blob/master/doc/http-grpc-status-mapping.md
766762
let statusHeader = trailers.first(name: ":status")
767-
guard let status = statusHeader.flatMap(Int.init).map({ HTTPResponseStatus(statusCode: $0) })
768-
else {
763+
let httpResponseStatus = statusHeader.flatMap(Int.init).map {
764+
HTTPResponseStatus(statusCode: $0)
765+
}
766+
767+
guard let httpResponseStatus = httpResponseStatus else {
769768
return .failure(.invalidHTTPStatus(statusHeader))
770769
}
771770

772-
guard status == .ok else {
773-
if let code = self.readStatusCode(from: trailers) {
774-
let message = self.readStatusMessage(from: trailers)
775-
return .failure(.invalidHTTPStatusWithGRPCStatus(.init(code: code, message: message)))
776-
} else {
777-
return .failure(.invalidHTTPStatus(statusHeader))
778-
}
771+
guard httpResponseStatus == .ok else {
772+
// Non-200 response. If there's a 'grpc-status' message we should use that otherwise try
773+
// to create one from the HTTP status code.
774+
let grpcStatusCode = self.readStatusCode(from: trailers)
775+
?? GRPCStatus.Code(httpStatus: Int(httpResponseStatus.code))
776+
?? .unknown
777+
let message = self.readStatusMessage(from: trailers)
778+
return .success(GRPCStatus(code: grpcStatusCode, message: message))
779779
}
780780

781781
// Only validate the content-type header if it's present. This is a small deviation from the

Sources/GRPC/GRPCError.swift

+16-8
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ public enum GRPCError {
229229

230230
public func makeGRPCStatus() -> GRPCStatus {
231231
return GRPCStatus(
232-
code: .init(httpStatus: self.status),
232+
code: .init(httpStatus: self.status) ?? .unknown,
233233
message: self.description,
234234
cause: self
235235
)
@@ -342,21 +342,29 @@ extension GRPCErrorProtocol {
342342
extension GRPCStatus.Code {
343343
/// The gRPC status code associated with the given HTTP status code. This should only be used if
344344
/// the RPC did not return a 'grpc-status' trailer.
345-
internal init(httpStatus: String?) {
345+
internal init?(httpStatus codeString: String?) {
346+
if let code = codeString.flatMap(Int.init) {
347+
self.init(httpStatus: code)
348+
} else {
349+
return nil
350+
}
351+
}
352+
353+
internal init?(httpStatus: Int) {
346354
/// See: https://github.com/grpc/grpc/blob/master/doc/http-grpc-status-mapping.md
347355
switch httpStatus {
348-
case "400":
356+
case 400:
349357
self = .internalError
350-
case "401":
358+
case 401:
351359
self = .unauthenticated
352-
case "403":
360+
case 403:
353361
self = .permissionDenied
354-
case "404":
362+
case 404:
355363
self = .unimplemented
356-
case "429", "502", "503", "504":
364+
case 429, 502, 503, 504:
357365
self = .unavailable
358366
default:
359-
self = .unknown
367+
return nil
360368
}
361369
}
362370
}

Tests/GRPCTests/GRPCClientStateMachineTests.swift

+4-10
Original file line numberDiff line numberDiff line change
@@ -1097,14 +1097,8 @@ extension GRPCClientStateMachineTests {
10971097
":status": "418",
10981098
"grpc-status": "5",
10991099
]
1100-
stateMachine.receiveEndOfResponseStream(trailers).assertFailure { error in
1101-
XCTAssertEqual(
1102-
error,
1103-
.invalidHTTPStatusWithGRPCStatus(GRPCStatus(
1104-
code: GRPCStatus.Code(rawValue: 5)!,
1105-
message: nil
1106-
))
1107-
)
1100+
stateMachine.receiveEndOfResponseStream(trailers).assertSuccess { status in
1101+
XCTAssertEqual(status.code.rawValue, 5)
11081102
}
11091103
}
11101104

@@ -1115,8 +1109,8 @@ extension GRPCClientStateMachineTests {
11151109
))
11161110

11171111
let trailers: HPACKHeaders = [":status": "418"]
1118-
stateMachine.receiveEndOfResponseStream(trailers).assertFailure { error in
1119-
XCTAssertEqual(error, .invalidHTTPStatus("418"))
1112+
stateMachine.receiveEndOfResponseStream(trailers).assertSuccess { status in
1113+
XCTAssertEqual(status.code, .unknown)
11201114
}
11211115
}
11221116
}

Tests/GRPCTests/GRPCStatusCodeTests.swift

+67-7
Original file line numberDiff line numberDiff line change
@@ -117,14 +117,74 @@ class GRPCStatusCodeTests: GRPCTestCase {
117117

118118
self.sendRequestHead()
119119
let headerFramePayload = HTTP2Frame.FramePayload.headers(.init(headers: headers))
120-
XCTAssertThrowsError(try self.channel.writeInbound(headerFramePayload)) { error in
121-
guard let withContext = error as? GRPCError.WithContext,
122-
let invalidHTTPStatus = withContext.error as? GRPCError.InvalidHTTPStatusWithGRPCStatus
123-
else {
124-
XCTFail("Unexpected error: \(error)")
125-
return
120+
try self.channel.writeInbound(headerFramePayload)
121+
122+
let responsePart1 = try XCTUnwrap(
123+
self.channel.readInbound(as: _GRPCClientResponsePart<ByteBuffer>.self)
124+
)
125+
126+
switch responsePart1 {
127+
case .trailingMetadata:
128+
()
129+
case .initialMetadata, .message, .status:
130+
XCTFail("Unexpected response part \(responsePart1)")
131+
}
132+
133+
let responsePart2 = try XCTUnwrap(
134+
self.channel.readInbound(as: _GRPCClientResponsePart<ByteBuffer>.self)
135+
)
136+
137+
switch responsePart2 {
138+
case .initialMetadata, .message, .trailingMetadata:
139+
XCTFail("Unexpected response part \(responsePart2)")
140+
case let .status(actual):
141+
XCTAssertEqual(actual.code, status.code)
142+
XCTAssertEqual(actual.message, status.message)
143+
}
144+
}
145+
146+
func testNon200StatusCodesAreConverted() throws {
147+
let tests: [(Int, GRPCStatus.Code)] = [
148+
(400, .internalError),
149+
(401, .unauthenticated),
150+
(403, .permissionDenied),
151+
(404, .unimplemented),
152+
(429, .unavailable),
153+
(502, .unavailable),
154+
(503, .unavailable),
155+
(504, .unavailable),
156+
]
157+
158+
for (httpStatusCode, grpcStatusCode) in tests {
159+
let headers: HPACKHeaders = [":status": "\(httpStatusCode)"]
160+
161+
self.setUp()
162+
self.sendRequestHead()
163+
let headerFramePayload = HTTP2Frame.FramePayload
164+
.headers(.init(headers: headers, endStream: true))
165+
try self.channel.writeInbound(headerFramePayload)
166+
167+
let responsePart1 = try XCTUnwrap(
168+
self.channel.readInbound(as: _GRPCClientResponsePart<ByteBuffer>.self)
169+
)
170+
171+
switch responsePart1 {
172+
case .trailingMetadata:
173+
()
174+
case .initialMetadata, .message, .status:
175+
XCTFail("Unexpected response part \(responsePart1)")
176+
}
177+
178+
let responsePart2 = try XCTUnwrap(
179+
self.channel.readInbound(as: _GRPCClientResponsePart<ByteBuffer>.self)
180+
)
181+
182+
switch responsePart2 {
183+
case .initialMetadata, .message, .trailingMetadata:
184+
XCTFail("Unexpected response part \(responsePart2)")
185+
case let .status(actual):
186+
XCTAssertEqual(actual.code, grpcStatusCode)
126187
}
127-
XCTAssertEqual(invalidHTTPStatus.makeGRPCStatus(), status)
128188
}
129189
}
130190

0 commit comments

Comments
 (0)