GRPC Status With Error Details in Swift
Introduction
In GRPC, one could define an RPC that, in addition to the normal request-response messages, it also defines a custom message to represent errors:
message SignUpWithEmailRequest {
string email = 1;
string password = 2;
string referral_code = 3;
}
message SignUpWithEmailResponse {
AccessTokenDTO token = 1;
}
message SignUpWithEmailErrorResponse {
enum Kind {
KIND_UNKNOWN = 0;
KIND_EMAIL_ALREADY_REGISTERED = 1;
KIND_INVALID_PASSWORD = 2;
KIND_INVALID_EMAIL = 3;
KIND_INVALID_CODE = 4;
}
Kind kind = 1;
repeated string reasons = 2;
}
service AuthenticationService {
rpc SignUpWithEmail(SignUpWithEmailRequest) returns (SignUpWithEmailResponse) {}
}
... in this example, SignUpWithEmailErrorResponse
is not directly referenced
in by AuthenticationService
. But a server can use it as GRPC status with
details. In Go the code might look like this:
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// ...
_, err = queries.GetUserByEmail(ctx, email)
if err == nil {
response := &SignUpWithEmailErrorResponse{
Kind: SignUpWithEmailErrorResponse_KIND_EMAIL_ALREADY_REGISTERED,
Reasons: []string{},
}
st := status.New(codes.AlreadyExists, "Email already registered")
stWithDetails, err := st.WithDetails(response)
if err != nil {
return nil, err
}
return nil, stWithDetails.Err()
}
This is all very type-safe, very demure, until you realize that in grpc-swift 1.X there's no API to retrieve this "status with detail". When the information is transimitted over the wire, you will have to dig it out manually. In this post, I'll document how I did this with a client-side interceptor.
The Swift interceptor
In Swift, when you make the RPC request, you'll get a standard error code and error message if the server returns an error with the code shown earlier:
// Use the generated client code to make the gRPC request
var request = SignUpWithEmailRequest()
request.email = // ...
request.password = // ...
request.referralCode = // ...
do {
let response = try await client.signUpWithEmail(request)
} catch {
guard let error = error as? GRPCStatus else {
print("Error: \(error)")
return
}
print(error.code) // AlreadyExists
print(error.message) // "Email already registered"
}
But, you, being a responsible client developer/tech lead/CTO, want to use the type-safe enum from the protobuf definition so that you can display the error in the right context, or perhaps localize it properly.
Here's the big picture: there may be many such custom RPC error types. Our solution should be universal, and flexible to handle each of them. Enter interceptors! I mean, chances are, you know about them because you are working with gRPC. Let's write one to get our type-safe status details. Starting with a custom receive method, for the ".end" part of the response:
final class GRPCErrorDetailsInterceptor<Request, Response>:
ClientInterceptor<Request, Response>, @unchecked Sendable
{
override func receive(
_ part: GRPCClientResponsePart<Response>,
context: ClientInterceptorContext<Request, Response>
) {
switch part {
case .end(var status, let headers):
// extract the error details, and forward it.
default:
context.receive(part)
}
}
}
... the "end" part contains the error status, as well as some trailing metadata.
The metadata includes our status details under the key grpc-status-details-bin
.
It's base64 encoded, so we'll need to decode it...
switch part {
case .end(var status, let headers):
guard
// grab the status details
let statusDetails = headers["grpc-status-details-bin"].first,
// decode to data
let data = Data(base64Encoded: statusDetails),
// ...
default:
context.receive(part)
}
At this point, with some experience with GRPC in Swift, you might think it's
time to instantiate your custom error type with .init(seralizedData:)
. But
there'd be 2 problems:
- You don't want each custom types from protobuf to make an appearance in an interceptor.
- This data would not be in the right shape, despite what the metadata key says.
In fact, the data is of the well-known type Google_Rpc_Status
. And our stutus
details, well, one its .details
element. So:
switch part {
case .end(var status, let headers):
guard
let statusDetails = headers["grpc-status-details-bin"].first,
let data = Data(base64Encoded: statusDetails),
// the data, despite being under "grpc-status-details-bin", is
// indeed not the status detail, but the statu itself:
let googleStatus = try? Google_Rpc_Status(serializedData: data)
// and the `details` field contains the actual status detail:
let details = googleStatus.details.first,
else {
context.receive(part)
break
}
// ...
default:
context.receive(part)
}
... details
is of type Google_Protobuf_Any
. It is indeed a payload with the
content for SignUpWithEmailErrorResponse
as defined in the Protobuf. One
question remains: how do we pass it from our intereceptor to the RPC call site?
Look at the call site from earlier: we have 2 code paths. If the call succeeds,
we get a SignUpWithEmailResponse
. If it fails, we get a GRPCStatus
as the
thrown error. Lucky for us, GRPCStatus
has an unused field, cause
. In my
version of grpc-swift
, the field has the following docstring:
/// The cause of an error (not 'ok') status. This value is never transmitted
/// over the wire and is **not** included in equality checks.
public var cause: Error? { ... }
It seems like a perfect vessel for our status details!
switch part {
case .end(var status, let headers):
guard
let statusDetails = headers["grpc-status-details-bin"].first,
let data = Data(base64Encoded: statusDetails),
let googleStatus = try? Google_Rpc_Status(serializedData: data)
let details = googleStatus.details.first,
else {
context.receive(part)
break
}
// isn't it convenient that we declared `status` as a `var` ealier 馃槈?
status.cause = details
// forward to the caller, yay!
context.receive(.end(status, headers))
default:
context.receive(part)
}
Now our client will get the details of type Google_Protobuf_Any
from the
.cause
field of the thrown error. The client can proceed to decode it using
the protobuf-generated specific type with its built-in .init(decodingAny:)
initializer:
// Use the generated client code to make the gRPC request
var request = SignUpWithEmailRequest()
request.email = // ...
request.password = // ...
request.referralCode = // ...
do {
let response = try await client.signUpWithEmail(request)
} catch {
guard let error = error as? GRPCStatus else {
print("Error: \(error)")
return
}
// let's be type-safe, finally!
guard
let details = error.cause as? Google_Protobuf_Any,
let signUpError = try? SignUpWithEmailErrorResponse(decodingAny: details)
else {
print("Error: \(error)")
return
}
// 馃帀
switch signUpError.kind {
// ...
}
}
Conclusion
I find this to be clean, targeted solution. Knowing the error detail's transmission format is key to making this work. The fact that we also got a clean architecture from exploiting an unused field is also very cool.