How gRPC Represents Errors
Unlike REST's overloaded use of HTTP status codes, gRPC defines a fixed enum of 17 canonical status codes (OK, CANCELLED, INVALID_ARGUMENT, NOT_FOUND, ALREADY_EXISTS, PERMISSION_DENIED, RESOURCE_EXHAUSTED, FAILED_PRECONDITION, UNAVAILABLE, DEADLINE_EXCEEDED, UNAUTHENTICATED, INTERNAL, and others) that every RPC returns alongside an optional human-readable message, and these codes are transmitted as HTTP/2 trailers rather than the leading HTTP status, since the RPC status is only known after the handler finishes, possibly after streaming many messages. A server returns an error by constructing a status.Error(code, message) (Go) or raising a grpc.StatusCode-mapped exception (Python, via context.abort()), and the client receives it as a language-native error object carrying that same code, decoupling error semantics entirely from any transport-layer HTTP status.
Cricket analogy: gRPC's fixed set of 17 status codes is like the ICC's fixed list of dismissal types (bowled, caught, lbw, run out, and so on) — every out must map to one of those defined categories, not an ad-hoc description.
Choosing the Right Status Code and Adding Detail
Selecting the correct status code matters because clients often branch on it programmatically: INVALID_ARGUMENT signals a client-fixable request problem (like a malformed field), FAILED_PRECONDITION signals the system state itself doesn't allow the operation (like deleting a non-empty directory), NOT_FOUND signals a missing resource, and INTERNAL should be reserved for genuine server bugs rather than expected business-rule failures, since clients typically treat INTERNAL as non-retryable and alert-worthy. For richer error information beyond a code and message, gRPC's google.rpc.Status proto supports attaching typed error details such as BadRequest, RetryInfo, or custom messages via the Any type, which language libraries expose through helpers like status.WithDetails() in Go, letting clients extract structured, machine-readable context instead of parsing message strings.
Cricket analogy: Choosing INVALID_ARGUMENT versus FAILED_PRECONDITION is like distinguishing an illegal bowling action (fixable technique, like INVALID_ARGUMENT) from a rain-affected pitch making play impossible right now (system state, like FAILED_PRECONDITION).
func (s *server) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.User, error) {
if req.GetEmail() == "" {
st := status.New(codes.InvalidArgument, "email is required")
st, _ = st.WithDetails(&errdetails.BadRequest{
FieldViolations: []*errdetails.BadRequest_FieldViolation{
{Field: "email", Description: "must not be empty"},
},
})
return nil, st.Err()
}
existing, err := s.repo.FindByEmail(ctx, req.GetEmail())
if err != nil && !errors.Is(err, sql.ErrNoRows) {
// Unexpected DB failure: genuine internal error
return nil, status.Errorf(codes.Internal, "lookup failed: %v", err)
}
if existing != nil {
return nil, status.Errorf(codes.AlreadyExists, "user with email %s already exists", req.GetEmail())
}
user, err := s.repo.Create(ctx, req)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create user: %v", err)
}
return user, nil
}
// Client-side handling
resp, err := client.CreateUser(ctx, req)
if err != nil {
st, _ := status.FromError(err)
switch st.Code() {
case codes.InvalidArgument:
log.Printf("bad request: %s", st.Message())
case codes.AlreadyExists:
log.Printf("duplicate: %s", st.Message())
default:
log.Printf("unexpected error: %v", st.Code())
}
}Propagating and Recovering from Errors
In multi-hop microservice call chains, an error's status code should be preserved as it propagates upward rather than collapsed into a generic INTERNAL error at each hop, since a client three services away still needs to distinguish a NOT_FOUND from an UNAVAILABLE to decide whether to retry; this typically means wrapping and re-raising the original status.Status rather than catching and re-throwing a new generic error. On the server side, a recovery interceptor should catch panics (or unhandled exceptions) at the top of the interceptor chain and convert them into a proper codes.Internal status response instead of letting the panic crash the connection or, worse, leak a raw stack trace to the client, which is both an information disclosure risk and a poor client experience.
Cricket analogy: Preserving the original status code across hops resembles a scorer relaying the exact dismissal type (caught behind) up through the scoreboard system rather than the broadcast simplifying it to a vague 'out'.
Use google.golang.org/genproto/googleapis/rpc/errdetails (or the equivalent in other languages) to attach structured error details like BadRequest field violations or RetryInfo, giving clients machine-readable context beyond a plain string message.
Never let a raw panic or unhandled exception propagate out of a gRPC handler unrecovered; besides crashing the connection for all multiplexed streams sharing it, an unrecovered panic can leak internal stack traces or sensitive data to the client if not converted into a clean codes.Internal status.
- gRPC uses a fixed enum of canonical status codes transmitted as HTTP/2 trailers, entirely decoupled from HTTP status codes.
- INVALID_ARGUMENT signals a client-fixable request problem, FAILED_PRECONDITION signals a system-state block, and NOT_FOUND signals a missing resource.
- INTERNAL should be reserved for genuine server bugs, not expected business-rule failures, since clients treat it as non-retryable and alert-worthy.
- The google.rpc.Status proto supports structured error details like BadRequest and RetryInfo via status.WithDetails() for richer, machine-readable context.
- Error status codes should be preserved as errors propagate across multi-hop service chains rather than collapsed into a generic INTERNAL at every hop.
- A panic-recovery interceptor should sit at the top of the interceptor chain to convert unhandled panics into clean codes.Internal responses.
- Clients should branch on status.FromError(err).Code() rather than parsing the human-readable message string.
Practice what you learned
1. Where does gRPC transmit its status code and message for an RPC?
2. Which status code should be used when a client's request is well-formed but the current system state doesn't allow the operation, such as deleting a non-empty directory?
3. Why should INTERNAL be reserved for genuine server bugs rather than expected business-rule failures?
4. What is the purpose of attaching structured error details via google.rpc.Status and status.WithDetails()?
5. What should a panic-recovery interceptor do when it catches an unhandled panic in a gRPC handler?
Was this page helpful?
You May Also Like
Implementing a gRPC Server
Learn how to define a gRPC service, generate server stubs, implement unary and streaming handlers, and manage a production server's lifecycle safely.
Implementing a gRPC Client
Learn how to establish a gRPC channel, call unary and streaming methods with a generated stub, and configure retries and load balancing for production use.
gRPC Deadlines and Cancellation
Understand how gRPC deadlines bound RPC execution time, how they propagate across service chains, and how explicit cancellation stops unneeded work.
Related Reading
Related Study Notes in Web Development
Browse all study notesWebSockets Study Notes
Web Development · 30 topics
Web DevelopmentWebAssembly Study Notes
WebAssembly · 30 topics
Web DevelopmentSpring Boot Study Notes
Java · 30 topics
Web DevelopmentFlask Study Notes
Python · 30 topics
Web DevelopmentDjango Study Notes
Python · 30 topics
Web DevelopmentNext.js Study Notes
JavaScript · 30 topics