The gRPC specification defines exactly 17 status codes. INVALID_ARGUMENT for bad input. INTERNAL for server errors. UNAVAILABLE for temporary failures. But what do you do when a client sends a request with five malformed fields? Or when you want to tell them "quota exceeded — retry in 30 seconds"? The standard error model—a status code plus a string message—can't express the nuance that your API needs to handle gracefully. The gRPC rich error model solves this by layering structured error details on top of the basic status code, turning vague failures into actionable, machine-readable information that both clients and your observability platform can actually work with.
This guide explains how the rich error model works, when to use it, and how to implement it in practice—plus the gotchas that catch most teams.
The standard error model's limitations
Every gRPC call returns a status code and an optional string message. This works fine for simple cases:
service Checkout {
rpc ProcessPayment(PaymentRequest) returns (PaymentResponse);
}
message PaymentResponse {
bool success = 1;
}
When ProcessPayment fails, the server sends back INVALID_ARGUMENT with message "Missing credit card number". The client sees the code and message, and that's it.
The problem emerges when you need to express why the request failed in a way that clients—and your error tracking—can consume programmatically. Consider:
- Validation failures: "The email field is malformed" — which field? What was the value? What was the validation rule?
- Rate limiting: "You've exceeded your quota" — how long until you can retry? What quota?
- Retry semantics: "The server is temporarily down" — should the client retry? For how long?
- Dependency failures: "Payment processing failed" — was it the card network, the bank, or the payment processor?
String messages don't scale. Your client library can't parse them reliably. Your error tracking system sees 10,000 variations of INTERNAL: something went wrong and groups them into one giant bucket instead of identifying the real distinct failure modes.
Introducing the rich error model
Google's API design guide and the gRPC community define a richer error model: alongside the standard status code and message, the server can attach a google.rpc.Status proto message in the grpc-status-details-bin trailer (a metadata field sent after the body). This status message contains a list of Any-typed detail messages, each carrying specific, structured information about what went wrong.
// From google/rpc/status.proto
message Status {
int32 code = 1; // The status code (mirrors the gRPC code)
string message = 2; // Human-readable message
repeated google.protobuf.Any details = 3; // Structured details
}
The key insight: the status code answers the category of failure (INVALID_ARGUMENT, INTERNAL, etc.). The details answer the specifics — which field? What validation rule? When should you retry?
The rich error model is a convention from Google's API design guide, not a feature universally supported by every gRPC library. Go, Java, Python, and Node.js SDKs have good support, but smaller ecosystems may not. Always check your language's gRPC documentation before relying on it.
Standard detail types and when to use them
Google defines a standard set of detail message types (in the google.rpc package) that cover most error scenarios. Here's when to use each:
| Type | Use when | Example |
|---|---|---|
| ErrorInfo | You need to classify the error type for client routing | reason: "RATE_LIMIT_EXCEEDED", domain: "checkout.example.com" |
| RetryInfo | The client should retry, and you know when | Quota reset in 30 seconds; circuit breaker opens for 60s |
| QuotaFailure | The client exceeded a quota | 1000 requests/hour limit hit; storage quota full |
| BadRequest / FieldViolation | One or more input fields are invalid | field: "email", description: "must be a valid email address" |
| PreconditionFailure | A precondition wasn't met (not input validation) | "Must upgrade to Pro"; "Feature flag not enabled" |
| ResourceInfo | The error involves a specific resource | resource_type: "projects", resource_name: "projects/123" |
| Help | Where to find more information | Links to documentation or support pages |
| LocalizedMessage | Client-facing message in multiple languages | Pre-translated error messages for i18n |
| DebugInfo | Internal debugging details (stack traces, internal state) | Never send to untrusted clients |
The rich error model lets you attach multiple details to a single error. A validation failure might carry both a BadRequest (which fields failed) and an ErrorInfo (classification). A quota error might carry both QuotaFailure (which quota) and RetryInfo (when to retry).
Never send DebugInfo to clients you don't control. It often contains stack traces, internal server state, or database query logs. This is a critical sensitive data risk. Keep it for internal debugging only. If you need to surface helpful context to clients, use Help or LocalizedMessage instead.
Size and performance: trailer limits
gRPC trailers are metadata fields sent after the response body. Technically, there's no hard limit, but practical implementations often impose size caps:
- gRPC C/C++: trailers can grow large, but total message size including trailers is often limited to a few MB.
- Reverse proxies (Envoy, Nginx): may truncate or drop oversized trailers.
- Bandwidth cost: every detail you attach costs bytes on the wire.
Don't use the rich error model to attach megabytes of logs or stack traces. Keep details lean: include the essential structured data, and let your error tracking system fetch the full trace if needed.
Real code: server attaching details
Here's how a server attaches BadRequest details when validating user input. Using Go:
package main
import (
"context"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func ValidateCreateUser(req *CreateUserRequest) error {
st := status.New(codes.InvalidArgument, "validation failed")
// Attach field violations
bd := &errdetails.BadRequest{}
if req.Email == "" {
bd.FieldViolations = append(bd.FieldViolations, &errdetails.BadRequest_FieldViolation{
Field: "email",
Description: "email is required",
})
} else if !isValidEmail(req.Email) {
bd.FieldViolations = append(bd.FieldViolations, &errdetails.BadRequest_FieldViolation{
Field: "email",
Description: "must be a valid email address",
})
}
if req.Age < 0 || req.Age > 150 {
bd.FieldViolations = append(bd.FieldViolations, &errdetails.BadRequest_FieldViolation{
Field: "age",
Description: "must be between 0 and 150",
})
}
if len(bd.FieldViolations) > 0 {
st, _ := st.WithDetails(bd)
return st.Err()
}
return nil
}
In Python (using grpcio):
from google.rpc import error_details_pb2
from grpcio import codes, status
def validate_create_user(req):
errors = []
if not req.email:
errors.append(error_details_pb2.BadRequest.FieldViolation(
field="email",
description="email is required"
))
elif not is_valid_email(req.email):
errors.append(error_details_pb2.BadRequest.FieldViolation(
field="email",
description="must be a valid email address"
))
if errors:
bad_request = error_details_pb2.BadRequest(field_violations=errors)
st = status.Status(
code=codes.INVALID_ARGUMENT,
message="validation failed",
details=[bad_request]
)
raise status.from_call(st)
Real code: client extracting details
A client receives the rich error and extracts the details to decide what to do. In Go:
import "google.golang.org/genproto/googleapis/rpc/errdetails"
resp, err := client.CreateUser(ctx, req)
if err != nil {
st := status.Convert(err)
// Check the status code
if st.Code() == codes.InvalidArgument {
// Iterate over details
for _, detail := range st.Details() {
switch d := detail.(type) {
case *errdetails.BadRequest:
// Handle field validation
for _, fv := range d.GetFieldViolations() {
log.Printf("Field %s: %s", fv.Field, fv.Description)
}
case *errdetails.RetryInfo:
// Extract retry delay
duration := d.GetRetryDelay()
log.Printf("Retry after %v", duration)
}
}
}
}
A Python client:
from google.rpc import error_details_pb2
try:
resp = client.CreateUser(req)
except grpc.RpcError as e:
if e.code() == grpc.StatusCode.INVALID_ARGUMENT:
for detail in e.details():
# Details come as serialized Any messages; you must deserialize
if isinstance(detail, error_details_pb2.BadRequest):
for fv in detail.field_violations:
print(f"Field {fv.field}: {fv.description}")
Connecting to observability: code vs. details
Error tracking and distributed tracing systems thrive on structure—this is where the rich error model pays dividends for error handling and monitoring. When you send just a status code and a string, they see:
INVALID_ARGUMENT: validation failed— appears 10,000 times, fingerprinted as one issue.INTERNAL: something went wrong— a catch-all bucket for a dozen different root causes.
With the rich error model, your observability platform can:
- Extract and group by error details: All
INVALID_ARGUMENTfailures withfield=emailare one issue;field=agefailures are another. - Track retry semantics: Spot when clients are hammering a retry-able error (via
RetryInfo), and alert on retry storms. - Reduce MTTR: A structured error tells you immediately whether it's client-side validation, rate limiting, or a backend bug. String matching can't.
When you wire detailed errors into your observability system, your team stops digging through stack traces to infer "why did this fail?" The failure mode is right there in the details.
When not to use the rich error model
The rich error model is powerful, but it's not always necessary:
- Simple internal services: If both client and server are under your control and the status code + message are sufficient, skip it.
- Streaming responses: Trailers are sent at the very end. For streaming gRPC calls where the client needs to know about errors mid-stream, handle errors in-band (as messages in the stream) instead.
- Tight bandwidth constraints: Every detail byte costs. On high-latency networks, keep trailers lean.
Start with the standard model. Add rich details when your clients need to handle errors differently based on why they failed.
Test your rich error handling end-to-end. It's easy to attach details on the server side and forget to extract them on the client. Write integration tests that verify clients correctly deserialize and react to your custom error details.
Grounding in standards
The rich error model is formalized in:
- Google's API design guide — the canonical reference.
- gRPC documentation — implementation details per language.
- google/rpc/status.proto — the proto definitions.
Most modern gRPC libraries (Go, Java, Python, Node.js, C#) have built-in support. Verify support in your language before committing to the model.
Your API's errors are a contract with your clients. By making them structured and observable, you hand your team—and your error tracking system—the information they need to debug fast. The rich error model is how you do that in gRPC.
Start tracking errors in minutes
Add structured error details to your gRPC APIs, then track them with LightTrace to catch error patterns your string messages would hide — get started free.