You don't know how to handle errors in proto!

by Edgar Sipki

In programming, there are always several ways to solve the same problem. But not all of them are equally effective. Today we'll talk about error handling methods in gRPC - both good and not so good.
message Result { oneof response { error.v1.Error error = 1; info.v1.Info info = 2; } }
At first glance, it might seem that using oneof to represent either an error or a result is convenient. However, this approach introduces unnecessary complexity into the message exchange protocol and worsens code readability. gRPC provides built-in tools for error handling that allow elegant and efficient transmission of error information.
Why is using oneof for errors a bad idea? Firstly, it complicates the use of the standard gRPC error mechanism and status codes designed for this purpose. Secondly, it can lead to confusion on the client side when needing to distinguish between successful responses and errors.
The diagram shows how handling two types of requests complicates client logic
Error codes in gRPC
Error codes in the gRPC architecture are particularly important for effective interaction between the client and server. They help the client understand the cause of the problem and respond to it correctly.
Proper and efficient error handling in gRPC plays a key role in creating reliable and maintainable systems. Using standard error codes and gRPC mechanisms not only simplifies error handling on the client side but also ensures clarity and predictability of system behavior. Instead of using constructs like oneof for error handling, it's better to use gRPC's built-in capabilities for transmitting detailed error information.
Here's how you can use the gRPC code codes.NotFound to report the absence of something
import "google.golang.org/grpc/status" import "google.golang.org/grpc/codes" // ... err := status.Error(codes.NotFound, "cat not found") // ...
This approach simplifies error handling on the client side, which makes it easier to understand the structure of the response data. Moreover, errors returned via status.Error are converted to HTTP statuses when transported through gRPC-Gateway, in which case the errors become understandable even outside of gRPC
But what if we need more flexibility in the error response? For example, to add additional meta-info or custom error codes?
In the gRPC system itself, there is a possibility to attach additional data to the error - and thus expand the context of the problem:
import ( "google.golang.org/grpc/status" "google.golang.org/grpc/codes" "google.golang.org/genproto/googleapis/rpc/errdetails" ) // ... st := status.New(codes.BadRequest, "invalid parameter") // General error form errInfo := &errdetails.ErrorInfo{ Reason: "Insufficient funds in the account", Domain: "finance", Metadata: map[string]string{ "my_meta_info": "my_meta_details", }, } st, err := st.WithDetails(errInfo) if err != nil { return fmt.Sprintf("st.WithDetails: %w", err) } return st.Err()
But in cases where you want to receive more detailed errors - for example, with clarification of the problematic field. In this case, you can use the BadRequest type and specify more details about the error.
st := status.New(codes.InvalidArgument, "invalid parameter") details := &errdetails.BadRequest_FieldViolation{ Field: "user_id", Description: "value must be a valid UUID", } br := &errdetails.BadRequest{ FieldViolations: []*errdetails.BadRequest_FieldViolation{ details, // ... can add other error details }, } st, err := st.WithDetails(br) if err != nil { return fmt.Sprintf("Unexpected error attaching metadata: %w", err) } return st.Err()
Defining and using a custom error
But! What if the standard details options don't suit us? We can create our own error types! :)
First, let's define a custom error in the proto file. We need to create a message for the CustomErrorDetail error. It will contain information about errors related to user data:
syntax = "proto3"; package myerrors; message CustomErrorDetail { string reason = 1; string field = 2; string help = 3; }
Now that we have a custom error definition, we can use it to transmit more specific and detailed error information. This is especially useful when you need to point out specific fields or parameters that caused the error. Creating and using such a CustomErrorDetail in the server code allows not only reporting problems but also providing the client with recommendations for fixing them, making the interaction more transparent and efficient.
import ( "google.golang.org/grpc/status" "google.golang.org/grpc/codes" "google.golang.org/protobuf/types/known/anypb" "myerrors" ) // ... customErrorDetail := &myerrors.CustomErrorDetail{ Reason: "Value out of range", Field: "age", Help: "The age must be between 0 and 120", } st := status.New(codes.InvalidArgument, "invalid parameter") st, err = st.WithDetails(customErrorDetail) if err != nil { return fmt.Sprintf("Unexpected error attaching custom error detail: %w", err) } return st.Err()
Working with the client side
Now let's look at how the client side will interact with the error handling system in gRPC that we described earlier.
Handling standard errors
When a client receives a response from a gRPC server, it can check for errors using standard gRPC mechanisms, for example:
import ( "context" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "log" ) func main() { conn, err := grpc.Dial("localhost:50051", grpc.WithInsecure()) if err != nil { log.Fatalf("did not connect: %v", err) } defer conn.Close() client := NewYourServiceClient(conn) response, err := client.YourMethod(context.Background(), &YourRequest{}) if err != nil { st, ok := status.FromError(err) if ok { switch st.Code() { case codes.InvalidArgument: log.Println("Invalid argument error:", st.Message()) case codes.NotFound: log.Println("Not found error:", st.Message()) // Handle other error codes as needed default: log.Println("Unexpected error:", st.Message()) } } else { log.Fatalf("failed to call YourMethod: %v", err) } } else { log.Println("Response:", response) } }
Extracting additional error details
And now the most interesting part: for the client side to be able to extract details for analysis, we need to process these very details.
Here's how it can be done:
import ( "google.golang.org/grpc/status" "google.golang.org/genproto/googleapis/rpc/errdetails" "myerrors" "log" ) // ... func handleError(err error) { st, ok := status.FromError(err) if !ok { log.Fatalf("An unexpected error occurred: %v", err) } for _, detail := range st.Details() { switch t := detail.(type) { case *errdetails.BadRequest: // Processing bad request details for _, violation := range t.GetFieldViolations() { log.Printf("The field %s was wrong: %s\n", violation.GetField(), violation.GetDescription()) } case *myerrors.CustomErrorDetail: // Processing custom error details log.Printf("Custom error detail: Reason: %s, Field: %s, Help: %s\n", t.Reason, t.Field, t.Help) // Add processing for other error types as needed default: log.Printf("Received an unknown error detail type: %v\n", t) } } }
Conclusion
We've looked at how to use standard gRPC error codes, how to add additional data to errors, and how to create and handle custom errors. These approaches allow for a more flexible and detailed approach to error handling, which is especially important for complex systems where a simple error message may not be sufficient.
When designing an API, it's important to remember that the client side should be able to easily and unambiguously interpret server responses. Using standard gRPC mechanisms for errors helps achieve this goal, improving interaction between the client and server and making the system as a whole more robust and understandable.
By following these recommendations, you can significantly improve the quality and reliability of your gRPC services, which will ultimately lead to a higher level of user satisfaction.