12 - Error Handling in gRPC
📋 Jump to TakeawaysgRPC has its own error model. You don't return plain Go errors. You return status codes with messages, and optionally rich error details. Getting this right makes your API debuggable and your clients resilient.
Status Codes
gRPC defines a set of status codes similar to HTTP status codes but more specific:
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// Not found
return nil, status.Errorf(codes.NotFound, "link %s not found", shortCode)
// Invalid argument
return nil, status.Error(codes.InvalidArgument, "url cannot be empty")
// Permission denied
return nil, status.Error(codes.PermissionDenied, "not allowed to delete this link")
// Internal error
return nil, status.Errorf(codes.Internal, "database error: %v", err)
// Already exists
return nil, status.Errorf(codes.AlreadyExists, "short code %s already taken", code)The most common codes:
| Code | When to Use |
|---|---|
OK |
Success (returned automatically when error is nil) |
InvalidArgument |
Client sent bad data |
NotFound |
Resource doesn't exist |
AlreadyExists |
Resource already exists |
PermissionDenied |
Caller lacks permission |
Unauthenticated |
No valid credentials |
Internal |
Server bug, unexpected error |
Unavailable |
Service temporarily down (client should retry) |
DeadlineExceeded |
Timeout |
Unimplemented |
Method not implemented |
Don't Return Raw Go Errors
If you return a plain Go error, gRPC wraps it as codes.Unknown:
// ❌ Bad: client gets codes.Unknown with no useful info
return nil, fmt.Errorf("something went wrong")
// ✅ Good: client gets codes.Internal with a clear message
return nil, status.Errorf(codes.Internal, "failed to save link: %v", err)Always use status.Errorf or status.Error. The client needs the code to decide what to do (retry? show error? give up?).
Checking Errors on the Client
resp, err := client.GetLink(ctx, req)
if err != nil {
st, ok := status.FromError(err)
if !ok {
// Not a gRPC error, something else went wrong
log.Fatal(err)
}
switch st.Code() {
case codes.NotFound:
fmt.Println("link does not exist")
case codes.InvalidArgument:
fmt.Printf("bad request: %s\n", st.Message())
case codes.Unavailable:
fmt.Println("service is down, retrying...")
default:
fmt.Printf("unexpected error: %s (%s)\n", st.Message(), st.Code())
}
return
}Rich Error Details
Sometimes a status code and message aren't enough. You want to tell the client which field was invalid, or include a retry delay. gRPC supports attaching structured error details using the errdetails package.
First, add the dependency:
go get google.golang.org/genproto/googleapis/rpc/errdetailsThen use it:
import (
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (s *linkServer) CreateLink(ctx context.Context, req *pb.CreateLinkRequest) (*pb.CreateLinkResponse, error) {
if req.GetUrl() == "" {
st := status.New(codes.InvalidArgument, "validation failed")
detailed, _ := st.WithDetails(&errdetails.BadRequest{
FieldViolations: []*errdetails.BadRequest_FieldViolation{
{
Field: "url",
Description: "url is required",
},
},
})
return nil, detailed.Err()
}
// ... create the link
}Reading Error Details on the Client
resp, err := client.CreateLink(ctx, &pb.CreateLinkRequest{})
if err != nil {
st := status.Convert(err)
fmt.Printf("error: %s (%s)\n", st.Message(), st.Code())
for _, detail := range st.Details() {
switch d := detail.(type) {
case *errdetails.BadRequest:
for _, v := range d.GetFieldViolations() {
fmt.Printf(" field %s: %s\n", v.GetField(), v.GetDescription())
}
case *errdetails.RetryInfo:
fmt.Printf(" retry after %s\n", d.GetRetryDelay().AsDuration())
}
}
}
status.Convertvsstatus.FromError: Both extract a gRPC status from an error.FromErrorreturns a secondokboolean — if the error isn't a gRPC status,okis false.Convertalways returns a status (usingcodes.Unknownfor non-gRPC errors). UseFromErrorwhen you need to distinguish gRPC errors from other errors. UseConvertwhen you just want the status regardless.
Wrapping Internal Errors
Don't leak internal details to clients. Wrap database errors, file system errors, and other internal failures:
func (s *linkServer) GetLink(ctx context.Context, req *pb.GetLinkRequest) (*pb.GetLinkResponse, error) {
link, err := s.store.FindByCode(req.GetShortCode())
if err != nil {
// Log the real error for debugging
log.Printf("store error: %v", err)
// Return a generic error to the client
return nil, status.Error(codes.Internal, "failed to fetch link")
}
if link == nil {
return nil, status.Errorf(codes.NotFound, "link not found: %s", req.GetShortCode())
}
return &pb.GetLinkResponse{Link: link}, nil
}Log the real error server-side. Send a sanitized message to the client. Never expose stack traces, database queries, or internal paths.
Validation Helper
If you validate requests in multiple RPCs, extract a helper:
func validateCreateLinkRequest(req *pb.CreateLinkRequest) error {
if req.GetUrl() == "" {
return status.Error(codes.InvalidArgument, "url is required")
}
if len(req.GetUrl()) > 2048 {
return status.Error(codes.InvalidArgument, "url too long (max 2048 chars)")
}
return nil
}
func (s *linkServer) CreateLink(ctx context.Context, req *pb.CreateLinkRequest) (*pb.CreateLinkResponse, error) {
if err := validateCreateLinkRequest(req); err != nil {
return nil, err
}
// ... create the link
}Key Takeaways
- Always use
status.Errorfwith a propercodes.*value, never return raw Go errors codes.NotFound,codes.InvalidArgument, andcodes.Internalcover most cases- Use
status.FromErroron the client to extract the code and message - Rich error details (
errdetails) let you attach structured info like field violations - Log real errors server-side, send sanitized messages to clients
- Extract validation into helper functions to keep handlers clean