gRPC Error Handling

Every gRPC call can succeed or fail. gRPC defines a precise system for classifying errors using status codes — a fixed list of error categories. Proper error handling makes your service predictable: callers know exactly what went wrong and how to react.

How gRPC Returns Errors

HTTP REST uses status codes like 200, 404, and 500. gRPC uses its own set of codes sent in the trailing metadata frame after the last message. Every completed gRPC call has a status code — including successful ones (OK).

Successful call — trailing metadata:
  grpc-status:  0
  grpc-message: (empty)

Failed call — trailing metadata:
  grpc-status:  5
  grpc-message: user with id 42 not found

The 17 gRPC Status Codes

┌──────┬─────────────────────┬──────────────────────────────────────────────┐
│ Code │ Name                │ Meaning                                      │
├──────┼─────────────────────┼──────────────────────────────────────────────┤
│  0   │ OK                  │ Success                                      │
│  1   │ CANCELLED           │ Client cancelled the call                    │
│  2   │ UNKNOWN             │ Unclassified server error                    │
│  3   │ INVALID_ARGUMENT    │ Bad input from the client                    │
│  4   │ DEADLINE_EXCEEDED   │ Call took longer than the deadline           │
│  5   │ NOT_FOUND           │ Requested resource does not exist            │
│  6   │ ALREADY_EXISTS      │ Resource the client tried to create exists   │
│  7   │ PERMISSION_DENIED   │ Caller lacks permission for this action      │
│  8   │ RESOURCE_EXHAUSTED  │ Quota exceeded, rate limit hit               │
│  9   │ FAILED_PRECONDITION │ System not in state required for the call    │
│ 10   │ ABORTED             │ Concurrency conflict (e.g. transaction abort)│
│ 11   │ OUT_OF_RANGE        │ Value is out of valid range                  │
│ 12   │ UNIMPLEMENTED       │ Method not implemented on the server         │
│ 13   │ INTERNAL            │ Serious internal server fault                │
│ 14   │ UNAVAILABLE         │ Server is temporarily down (safe to retry)   │
│ 15   │ DATA_LOSS           │ Unrecoverable data loss or corruption        │
│ 16   │ UNAUTHENTICATED     │ No valid credentials supplied                │
└──────┴─────────────────────┴──────────────────────────────────────────────┘

Codes vs HTTP Status — Quick Mapping

┌─────────────────────────────┬───────────────────┐
│ gRPC Code                   │ HTTP Equivalent   │
├─────────────────────────────┼───────────────────┤
│ OK                          │ 200               │
│ INVALID_ARGUMENT            │ 400               │
│ UNAUTHENTICATED             │ 401               │
│ PERMISSION_DENIED           │ 403               │
│ NOT_FOUND                   │ 404               │
│ ALREADY_EXISTS              │ 409               │
│ RESOURCE_EXHAUSTED          │ 429               │
│ INTERNAL                    │ 500               │
│ UNAVAILABLE                 │ 503               │
│ DEADLINE_EXCEEDED           │ 504               │
└─────────────────────────────┴───────────────────┘

Returning Errors on the Server (Go)

import (
  "google.golang.org/grpc/codes"
  "google.golang.org/grpc/status"
)

func (s *server) GetUser(ctx context.Context,
    req *pb.GetUserRequest) (*pb.User, error) {

  // Validate the input
  if req.UserId <= 0 {
    return nil, status.Errorf(codes.InvalidArgument,
      "user_id must be positive, got %d", req.UserId)
  }

  user, found := s.db.Find(req.UserId)

  if !found {
    return nil, status.Errorf(codes.NotFound,
      "user %d not found", req.UserId)
  }

  if !s.hasPermission(ctx, user) {
    return nil, status.Errorf(codes.PermissionDenied,
      "caller cannot access user %d", req.UserId)
  }

  return user, nil   // nil error = OK status
}

Handling Errors on the Client (Go)

resp, err := client.GetUser(ctx, &pb.GetUserRequest{UserId: 42})
if err != nil {
  // Convert error to a gRPC status
  st, ok := status.FromError(err)
  if !ok {
    log.Printf("non-gRPC error: %v", err)
    return
  }

  // React based on the code
  switch st.Code() {
  case codes.NotFound:
    fmt.Println("User does not exist, showing 404 page")
  case codes.InvalidArgument:
    fmt.Printf("Bad request: %s\n", st.Message())
  case codes.Unauthenticated:
    fmt.Println("Session expired, redirecting to login")
  case codes.Unavailable:
    fmt.Println("Service down, will retry in 5 seconds")
    time.Sleep(5 * time.Second)
    // retry logic here
  default:
    fmt.Printf("Unexpected error [%s]: %s\n", st.Code(), st.Message())
  }
  return
}

fmt.Printf("Got user: %s\n", resp.Name)

Rich Error Details

A status code and a message string often are not enough. The gRPC ecosystem defines structured error types from Google's API design guidelines that carry machine-readable details.

Available detail types (from google.golang.org/grpc/status):
┌───────────────────────┬─────────────────────────────────────────────┐
│ Detail Type           │ Use For                                     │
├───────────────────────┼─────────────────────────────────────────────┤
│ ErrorInfo             │ Domain, reason, metadata about the error    │
│ BadRequest            │ List of invalid fields with descriptions    │
│ RetryInfo             │ When the client should retry                │
│ ResourceInfo          │ Which resource was not found or exhausted   │
│ QuotaFailure          │ Which quota was exceeded and by how much    │
│ PreconditionFailure   │ Which precondition was violated             │
└───────────────────────┴─────────────────────────────────────────────┘
Adding field validation details on the server (Go):
  import (
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
    epb "google.golang.org/genproto/googleapis/rpc/errdetails"
  )

  func (s *server) CreateUser(ctx context.Context,
      req *pb.CreateUserRequest) (*pb.User, error) {

    // Build a list of field violations
    var violations []*epb.BadRequest_FieldViolation

    if req.Email == "" {
      violations = append(violations, &epb.BadRequest_FieldViolation{
        Field:       "email",
        Description: "email is required",
      })
    }
    if len(req.Password) < 8 {
      violations = append(violations, &epb.BadRequest_FieldViolation{
        Field:       "password",
        Description: "password must be at least 8 characters",
      })
    }

    if len(violations) > 0 {
      st, _ := status.New(codes.InvalidArgument, "validation failed").
        WithDetails(&epb.BadRequest{FieldViolations: violations})
      return nil, st.Err()
    }

    return createUser(req), nil
  }
Reading detail errors on the client:
  import epb "google.golang.org/genproto/googleapis/rpc/errdetails"

  st, _ := status.FromError(err)
  for _, detail := range st.Details() {
    switch v := detail.(type) {
    case *epb.BadRequest:
      for _, violation := range v.FieldViolations {
        fmt.Printf("Field '%s': %s\n", violation.Field, violation.Description)
      }
    case *epb.RetryInfo:
      retryAfter := v.RetryDelay.AsDuration()
      fmt.Printf("Retry after: %v\n", retryAfter)
    }
  }

Which Codes Are Safe to Retry?

┌─────────────────────┬──────────┬───────────────────────────────────┐
│ Code                │ Retry?   │ Reason                            │
├─────────────────────┼──────────┼───────────────────────────────────┤
│ UNAVAILABLE         │ YES      │ Temporary server issue            │
│ DEADLINE_EXCEEDED   │ YES*     │ *Only if the operation is safe    │
│ RESOURCE_EXHAUSTED  │ YES      │ Wait for rate limit window reset  │
│ INTERNAL            │ NO       │ Server bug — retry won't fix it   │
│ INVALID_ARGUMENT    │ NO       │ Request is wrong — fix it first   │
│ NOT_FOUND           │ NO       │ Resource does not exist           │
│ PERMISSION_DENIED   │ NO       │ Fix authorisation first           │
│ ALREADY_EXISTS      │ NO       │ Duplicate — do not retry          │
└─────────────────────┴──────────┴───────────────────────────────────┘

Error Wrapping Pattern

Avoid leaking internal details in error messages. Translate database errors and internal errors before returning them to callers.

Bad practice — leaks internals:
  return nil, status.Errorf(codes.Internal,
    "pq: duplicate key violates unique constraint on users_email_idx")

Good practice — clean message, code is specific enough to act on:
  return nil, status.Errorf(codes.AlreadyExists,
    "an account with this email already exists")

Summary

gRPC status codes replace HTTP status codes for service-to-service communication. The server returns an error by calling status.Errorf with a code and a message. The client checks the code with status.FromError and reacts appropriately. Rich error detail types like BadRequest let you surface field-level validation errors. Only retry on UNAVAILABLE, DEADLINE_EXCEEDED, and RESOURCE_EXHAUSTED.

Leave a Comment

Your email address will not be published. Required fields are marked *