gRPC Error Codes & Status Mapping
Errors cross process boundaries as gRPC status codes, not Go error strings.
Clients must inspect codes; servers must map domain failures deliberately.
Summary
google.golang.org/grpc/status wraps a codes.Code, message, and optional detail protobufs.
Servers return status.Error or status.Errorf; clients unwrap with status.FromError.
Sixteen standard codes cover most distributed systems scenarios.
Structured details (google.rpc.ErrorInfo, custom protos) let UIs and retries act on machine-readable fields.
Treating gRPC errors like opaque fmt.Errorf chains loses information at every hop.
Recipe
Quick-reference recipe card - copy-paste ready.
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func findUser(id string) error {
if id == "" {
return status.Error(codes.InvalidArgument, "id required")
}
if !exists(id) {
return status.Error(codes.NotFound, "user not found")
}
return nil
}// Client
st, ok := status.FromError(err)
if ok && st.Code() == codes.NotFound {
// handle missing resource
}When to reach for this:
- Returning validation failures (
InvalidArgument) vs missing entities (NotFound). - Signaling overload (
ResourceExhausted) or maintenance (Unavailable) for retry logic. - Attaching
ErrorInfowithreasonanddomainfor API gateways. - Translating gRPC codes to HTTP status in grpc-gateway mappings.
Working Example
package main
import (
"context"
"fmt"
"log"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/anypb"
)
type inventoryServer struct{}
func (s *inventoryServer) Reserve(ctx context.Context, sku string, qty int32) error {
if qty <= 0 {
st := status.New(codes.InvalidArgument, "quantity must be positive")
br := &errdetails.BadRequest{
FieldViolations: []*errdetails.BadRequest_FieldViolation{
{Field: "qty", Description: "must be > 0"},
},
}
st, err := st.WithDetails(br)
if err != nil {
return status.Error(codes.Internal, "detail attach failed")
}
return st.Err()
}
if !inStock(sku, qty) {
info := &errdetails.ErrorInfo{
Reason: "INSUFFICIENT_STOCK",
Domain: "inventory.example.com",
Metadata: map[string]string{"sku": sku},
}
st, _ := status.New(codes.FailedPrecondition, "not enough stock").WithDetails(info)
return st.Err()
}
return nil
}
func clientHandle(err error) {
st, ok := status.FromError(err)
if !ok {
log.Fatal(err)
}
fmt.Println("code:", st.Code(), "msg:", st.Message())
for _, d := range st.Details() {
switch info := d.(type) {
case *errdetails.ErrorInfo:
fmt.Println("reason:", info.GetReason())
case *errdetails.BadRequest:
for _, v := range info.GetFieldViolations() {
fmt.Println(v.GetField(), v.GetDescription())
}
default:
if any, ok := d.(*anypb.Any); ok {
fmt.Println("unknown detail type:", any.GetTypeUrl())
}
}
}
}
func inStock(sku string, qty int32) bool { return false }
func main() {
srv := &inventoryServer{}
err := srv.Reserve(context.Background(), "ABC", 0)
clientHandle(err)
}What this demonstrates:
status.NewplusWithDetailsattaches standard detail messages.- Clients iterate
st.Details()with type switches. FailedPreconditionsignals business rule violations distinct fromNotFound.- Field-level validation uses
BadRequestdetails familiar to grpc-gateway JSON clients.
Deep Dive
How It Works
On failure, gRPC sends a status protobuf in trailers (HTTP/2 headers).
Go surfaces it as error satisfying status.FromError.
Wrapping with %w preserves status if the wrapper chain still exposes a gRPC status.
Plain errors.New becomes codes.Unknown unless converted.
Common Code Mapping
| Code | Meaning | HTTP analog (gateway) | Retry? |
|---|---|---|---|
OK | Success | 200 | - |
InvalidArgument | Bad input | 400 | No |
NotFound | Missing resource | 404 | No |
AlreadyExists | Duplicate create | 409 | No |
PermissionDenied | Authz failure | 403 | No |
Unauthenticated | Authn failure | 401 | No |
ResourceExhausted | Rate limit / quota | 429 | Backoff |
Unavailable | Transient outage | 503 | Yes, with jitter |
DeadlineExceeded | Timeout | 504 | Maybe |
Canceled | Client cancelled | 499 | No |
Internal | Server bug | 500 | No |
Go Notes
// Preserve status through helper layers
func wrap(err error, msg string) error {
if err == nil {
return nil
}
if _, ok := status.FromError(err); ok {
return fmt.Errorf("%s: %w", msg, err)
}
return status.Errorf(codes.Internal, "%s: %v", msg, err)
}- Do not return
codes.Internalfor predictable validation errors - clients cannot remediate. - Log server-side stack traces; return safe messages on the wire.
- Use
status.Convertwhen bridging legacy APIs.
Gotchas
- String-matching error messages - Breaks localization and refactors. Fix: compare
status.Code(err). - Returning raw panics - Becomes obscure
Internalerrors. Fix: recovery interceptor plus explicit status mapping. - Mapping everything to
Unknown- Retry storms and bad alerts. Fix: pick the closest standard code. - Huge detail payloads - Trailers exceed limits. Fix: return IDs referencing a lookup endpoint.
- Losing details through gateways - JSON mapping may drop unknown types. Fix: register
google.rpctypes in gateway config. context.Canceledvscodes.Canceled- Client disconnect may surface differently. Fix: normalize in client libraries.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| gRPC status + details | All gRPC services | - |
error return only in-process | Private packages never crossing RPC | Public service boundaries |
| HTTP problem+json at edge | Browser-only BFF | Internal protobuf RPC |
| Result enums in response | Business outcomes are expected branches | True failures (auth, bugs) |
FAQs
Should handlers return error or status?
Return status.Error values as error.
Callers use the same error interface everywhere.
How do I log without leaking internals?
Log full errors server-side.
Return generic Internal messages to clients unless safe details are intentional.
What is FailedPrecondition vs Aborted?
FailedPrecondition means do not retry until system state changes.
Aborted suggests retry may succeed immediately (concurrency conflict).
Can I attach custom proto details?
Yes - pack with anypb.New and register types clients understand.
How do gateways map codes?
Default rules map gRPC codes to HTTP status.
Override in google.api.http annotations when needed.
Does errors.Is work with status?
Use status.FromError first.
Wrapped status errors may need custom Is logic.
What about multi-error validation?
One InvalidArgument with multiple BadRequest field violations is idiomatic.
Should clients retry Internal?
No - alert and surface failure.
Retry Unavailable with capped exponential backoff.
How do streaming errors appear?
Often as the error return from the final Recv or Send.
Trailers carry status after stream half-close.
How does this relate to Go 1.13 errors?
Status errors participate in wrapping.
Domain packages can define sentinel errors converted at the transport boundary.
Related
- gRPC Basics - first status returns
- gRPC Middleware, Interceptors & Metadata - auth error mapping
- gRPC Gateway & JSON Transcoding - HTTP status mapping
- Error Wrapping with %w & errors.As - in-process errors
- REST vs gRPC Trade-off Guide - error ergonomics trade-off
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).