12 - Error Handling in gRPC

📋 Jump to Takeaways

gRPC 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/errdetails

Then 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.Convert vs status.FromError: Both extract a gRPC status from an error. FromError returns a second ok boolean — if the error isn't a gRPC status, ok is false. Convert always returns a status (using codes.Unknown for non-gRPC errors). Use FromError when you need to distinguish gRPC errors from other errors. Use Convert when 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.Errorf with a proper codes.* value, never return raw Go errors
  • codes.NotFound, codes.InvalidArgument, and codes.Internal cover most cases
  • Use status.FromError on 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

💻 Examples

Complete examples for this lesson. Copy and run locally.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue

© 2026 ByteLearn.dev. Free courses for developers. · Privacy