Error Handling & Interceptors

gRPC status codes, and unary interceptors for logging and authentication, with a complete example.

gRPC status codes

REST APIs signal errors with HTTP status codes; gRPC has its own, separate mechanism, because a gRPC call isn't shaped like an HTTP request/response in the way REST is. Every RPC completes with a status — a code from a fixed, canonical enumeration (the codes package) plus a human-readable message:

gRPC code Meaning Rough REST/HTTP equivalent
OK Success 200
InvalidArgument The client sent malformed or invalid data 400
Unauthenticated No valid credentials were supplied 401
PermissionDenied Authenticated, but not allowed to do this 403
NotFound The requested entity doesn't exist 404
AlreadyExists The entity being created already exists 409
Internal An unexpected server-side bug 500
Unavailable The server is temporarily unreachable — safe to retry 503

Returning one from a server handler:

Go
func (s *taskServer) GetTask(ctx context.Context, req *pb.GetTaskRequest) (*pb.Task, error) {
    task, ok := s.tasks[req.Id]
    if !ok {
        return nil, status.Errorf(codes.NotFound, "task %d not found", req.Id)
    }
    return task, nil
}

And checking for it on the client:

Go
resp, err := client.GetTask(ctx, &pb.GetTaskRequest{Id: 999})
if err != nil {
    st, _ := status.FromError(err)
    if st.Code() == codes.NotFound {
        // handle the missing-task case specifically
    }
}

status.FromError unpacks any error returned by a gRPC call back into its code and message — a plain Go error returned from a handler (via errors.New(...), for instance, instead of status.Errorf) still reaches the client, but as a generic codes.Unknown, losing whatever specific meaning the server actually intended.

Unary interceptors

An interceptor is gRPC's equivalent of HTTP middleware — a function that wraps every call to a service, given the chance to run logic before and after the actual handler, exactly like the middleware chains covered elsewhere in this app for ASP.NET Core and Gin. A unary interceptor wraps unary (single request/single response) calls specifically.

A logging interceptor and an authentication interceptor, combined:

Go
func LoggingInterceptor(
    ctx context.Context,
    req any,
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (any, error) {
    start := time.Now()

    resp, err := handler(ctx, req) // call the next interceptor, or the actual RPC handler

    log.Printf("%s took %s, error=%v", info.FullMethod, time.Since(start), err)
    return resp, err
}

func AuthInterceptor(
    ctx context.Context,
    req any,
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (any, error) {
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok || len(md["authorization"]) == 0 {
        return nil, status.Error(codes.Unauthenticated, "missing authorization metadata")
    }

    token := md["authorization"][0]
    if token != "Bearer expected-token" {
        return nil, status.Error(codes.Unauthenticated, "invalid token")
    }

    return handler(ctx, req)
}

Registering several interceptors chains them together, in order — the first one listed is outermost, wrapping every interceptor and handler after it, the same "onion" model used by every other middleware system in this app:

Go
srv := grpc.NewServer(
    grpc.ChainUnaryInterceptor(LoggingInterceptor, AuthInterceptor),
)

With this registration, LoggingInterceptor runs first on the way in (and last on the way out, after the response is available), wrapping AuthInterceptor, which in turn wraps the actual RPC handler — so every call gets both logged and authenticated without either concern living inside the handler itself.

Common mistakes

  • Returning a plain Go error (errors.New(...) or fmt.Errorf(...)) from a handler instead of status.Error/status.Errorf — the client still receives an error, but as a generic codes.Unknown, losing the ability to branch on what actually went wrong.
  • Duplicating authentication or logging logic inside every single RPC handler instead of centralizing it in one interceptor — easy to forget on a newly-added handler, and hard to change consistently later.
  • Registering a unary interceptor and assuming it also covers streaming RPCs — streaming calls need their own registration via grpc.ChainStreamInterceptor; a unary interceptor alone never runs for a streaming method.

Interview questions

Q: How does a gRPC service signal an application-level error, given that it doesn't use HTTP status codes the way REST does? Through a status, built with status.Error/status.Errorf and a code from the codes package (a fixed enumeration like NotFound, InvalidArgument, Unauthenticated). The client unpacks it with status.FromError to branch on the specific code, rather than parsing an HTTP status or a string message.

Q: What happens if a gRPC handler returns a plain Go error instead of a status error? The call still fails and the client still receives an error, but it arrives as the generic codes.Unknown rather than whatever specific status the server actually meant — the client loses the ability to distinguish, say, "not found" from "invalid argument" without inspecting the error message as a string.

Q: What is a unary interceptor, and what's a typical use for one? gRPC's equivalent of HTTP middleware — a function that wraps every unary RPC call, running logic before and after the actual handler (or the next interceptor in the chain). Common uses include centralized logging (timing every call) and authentication (rejecting a call before it ever reaches the handler if required credentials are missing or invalid).