Unary RPCs in Go With Errors Deadlines and Metadata
Write unary gRPC calls in Go that handle status codes, client deadlines, server-side cancellation, request metadata, response headers and trailers.

The unary RPC is the workhorse of gRPC: the client sends one message, the server returns one message. It looks like a function call, and the three things that make it a network call instead (failures, time limits and out-of-band information) all have precise answers in gRPC. This article covers each with a runnable service: status codes for errors, deadlines for time limits and metadata for the information that travels beside the message.
Tested with Go 1.25.6, grpc-go v1.75.0, protobuf-go v1.36.8, protoc 29.3, macOS on Apple Silicon (arm64)
It assumes the setup from the first gRPC service. The later articles on error codes and rich details, deadline propagation and metadata and context go further on each topic. Everything ran on Go 1.25.6.
The service
A small key-value store with three rpcs: Get, Put and SlowScan, which exists only to be slow.
syntax = "proto3";
package kv.v1;
option go_package = "example.test/lab/kvpb";
service Store {
rpc Get(GetRequest) returns (GetResponse);
rpc Put(PutRequest) returns (PutResponse);
rpc SlowScan(GetRequest) returns (GetResponse);
}
message GetRequest { string key = 1; }
message GetResponse { string value = 1; }
message PutRequest { string key = 1; string value = 2; }
message PutResponse { bool created = 1; }//go:build tools
package main
import (
_ "google.golang.org/grpc/cmd/protoc-gen-go-grpc"
_ "google.golang.org/protobuf/cmd/protoc-gen-go"
)The server implements all three and the program then calls them from a client in the same process:
package main
import (
"context"
"errors"
"fmt"
"log"
"net"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
pb "example.test/lab/kvpb"
)
type store struct {
pb.UnimplementedStoreServer
mu sync.Mutex
data map[string]string
scanSaw chan error // lets the demo observe what the server saw when the client gave up
}
func (s *store) Get(ctx context.Context, req *pb.GetRequest) (*pb.GetResponse, error) {
if req.GetKey() == "" {
return nil, status.Error(codes.InvalidArgument, "key is required")
}
// Metadata the client sent travels with the call; metadata we send back goes out as headers and trailers.
if md, ok := metadata.FromIncomingContext(ctx); ok {
if ids := md.Get("x-request-id"); len(ids) > 0 {
grpc.SetHeader(ctx, metadata.Pairs("x-request-id", ids[0]))
}
}
grpc.SetTrailer(ctx, metadata.Pairs("x-cache", "miss"))
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.data[req.GetKey()]
if !ok {
return nil, status.Errorf(codes.NotFound, "no value for key %q", req.GetKey())
}
return &pb.GetResponse{Value: v}, nil
}
func (s *store) Put(ctx context.Context, req *pb.PutRequest) (*pb.PutResponse, error) {
if req.GetKey() == "" || len(req.GetValue()) > 16 {
return nil, status.Error(codes.InvalidArgument, "key is required and value must be at most 16 bytes")
}
s.mu.Lock()
defer s.mu.Unlock()
_, existed := s.data[req.GetKey()]
s.data[req.GetKey()] = req.GetValue()
return &pb.PutResponse{Created: !existed}, nil
}
// SlowScan takes two seconds unless the caller's deadline arrives first, and reports what it observed.
func (s *store) SlowScan(ctx context.Context, req *pb.GetRequest) (*pb.GetResponse, error) {
select {
case <-time.After(2 * time.Second):
s.scanSaw <- nil
return &pb.GetResponse{Value: "done"}, nil
case <-ctx.Done():
s.scanSaw <- ctx.Err()
return nil, status.FromContextError(ctx.Err()).Err()
}
}
func main() {
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Fatal(err)
}
impl := &store{data: map[string]string{"greeting": "hello"}, scanSaw: make(chan error, 1)}
srv := grpc.NewServer()
pb.RegisterStoreServer(srv, impl)
go srv.Serve(lis)
defer srv.Stop()
conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatal(err)
}
defer conn.Close()
c := pb.NewStoreClient(conn)
describe := func(err error) string {
if err == nil {
return "OK"
}
st, ok := status.FromError(err)
if !ok {
return "non-gRPC error: " + err.Error()
}
return fmt.Sprintf("%s: %s", st.Code(), st.Message())
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
// 1. Success, with request metadata going out and response headers and trailers coming back.
var header, trailer metadata.MD
out := metadata.AppendToOutgoingContext(ctx, "x-request-id", "req-42")
resp, err := c.Get(out, &pb.GetRequest{Key: "greeting"}, grpc.Header(&header), grpc.Trailer(&trailer))
fmt.Printf("Get greeting -> %q %s | header x-request-id=%v trailer x-cache=%v\n", resp.GetValue(), describe(err), header.Get("x-request-id"), trailer.Get("x-cache"))
// 2. Errors are statuses with a code the caller can switch on.
_, err = c.Get(ctx, &pb.GetRequest{Key: "missing"})
fmt.Println("Get missing ->", describe(err), "| code is NotFound:", status.Code(err) == codes.NotFound)
_, err = c.Get(ctx, &pb.GetRequest{})
fmt.Println("Get empty key ->", describe(err))
_, err = c.Put(ctx, &pb.PutRequest{Key: "k", Value: "this value is far too long"})
fmt.Println("Put too long ->", describe(err))
p, _ := c.Put(ctx, &pb.PutRequest{Key: "k", Value: "v1"})
p2, _ := c.Put(ctx, &pb.PutRequest{Key: "k", Value: "v2"})
fmt.Println("Put twice -> created:", p.GetCreated(), "then", p2.GetCreated())
// 3. A deadline shorter than the work: the client gets DeadlineExceeded and the server's context is cancelled.
short, cancelShort := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancelShort()
start := time.Now()
_, err = c.SlowScan(short, &pb.GetRequest{Key: "all"})
fmt.Printf("SlowScan, 150ms limit -> %s after under 500ms: %v\n", describe(err), time.Since(start) < 500*time.Millisecond)
seen := <-impl.scanSaw // the server learns the client gave up as Canceled or DeadlineExceeded, depending on which signal arrives first
fmt.Println("server's context ended too:", errors.Is(seen, context.Canceled) || errors.Is(seen, context.DeadlineExceeded))
// 4. A plain Go error returned from a handler becomes codes.Unknown, which is rarely what you want.
var plain = errors.New("database exploded")
fmt.Println("plain error mapped to:", status.Code(plain), "- and nil maps to:", status.Code(nil))
}mkdir -p bin
go build -o bin/protoc-gen-go google.golang.org/protobuf/cmd/protoc-gen-go
go build -o bin/protoc-gen-go-grpc google.golang.org/grpc/cmd/protoc-gen-go-grpc
PATH="$PWD/bin:$PATH" protoc --go_out=. --go_opt=module=example.test/lab --go-grpc_out=. --go-grpc_opt=module=example.test/lab proto/*.proto
go run .Get greeting -> "hello" OK | header x-request-id=[req-42] trailer x-cache=[miss]
Get missing -> NotFound: no value for key "missing" | code is NotFound: true
Get empty key -> InvalidArgument: key is required
Put too long -> InvalidArgument: key is required and value must be at most 16 bytes
Put twice -> created: true then false
SlowScan, 150ms limit -> DeadlineExceeded: context deadline exceeded after under 500ms: true
server's context ended too: true
plain error mapped to: Unknown - and nil maps to: OKErrors are statuses
A gRPC call ends with a status: a code from a fixed list and a message. The client receives it in the trailers after the response. In Go, a handler returns an error, and the server converts it into a status. If the error is a status error (made with status.Error or status.Errorf), it keeps its code. Anything else becomes Unknown.
The lab's server returns codes.NotFound for a missing key, codes.InvalidArgument for an empty key and for a value that is too long. On the client, status.FromError(err) extracts the status and status.Code(err) returns just the code, so the caller writes if status.Code(err) == codes.NotFound, not a string comparison. This is the main practical difference from returning HTTP errors by hand: the vocabulary is shared by every language and every middleware.
The last line of the output shows the trap: errors.New("database exploded") maps to Unknown, and status.Code(nil) is OK. A handler that returns raw errors tells the client nothing useful, and exposes the message text. Convert domain errors to codes in one place, usually at the top of the handler or in an interceptor (interceptors), and log the real cause on the server side.
A short guide to choosing codes: InvalidArgument when the request is wrong regardless of state; NotFound for a missing resource; AlreadyExists for a duplicate create; FailedPrecondition when the system is in the wrong state for the call; PermissionDenied when the caller is known and not allowed; Unauthenticated when they are not known; ResourceExhausted for quotas and rate limits; Unavailable for transient failures that are safe to retry; DeadlineExceeded for timeouts; Internal for bugs. The error codes article goes through all of them.
Deadlines
A call without a deadline waits as long as the server takes. The fix is in the client's context: context.WithTimeout or WithDeadline. gRPC sends the remaining time to the server in the grpc-timeout header, so the server's handler context has the same deadline.
The SlowScan rpc takes two seconds. The client calls it with a 150 ms limit. The output shows DeadlineExceeded returned in under 500 ms, so the client was not stuck. The server did not know about the timeout in advance, but its handler's ctx.Done() channel fired, and the handler recorded that its context had ended (as Canceled or DeadlineExceeded, depending on whether the server's own timer or the client's cancellation frame arrived first; both mean "stop working").
The select in the handler is the pattern to copy:
select {
case <-time.After(2 * time.Second):
return &pb.GetResponse{Value: "done"}, nil
case <-ctx.Done():
return nil, status.FromContextError(ctx.Err()).Err()
}Every blocking step in a handler (database call, outgoing RPC, channel receive) should take the context or select on it, so work stops when the caller has gone. status.FromContextError turns a context error into the right status. If you ignore the context, the handler keeps running to completion for nobody, using a goroutine and a database connection. When the handler calls another service, pass the same context; the deadline then shrinks naturally as time passes (propagating deadlines).
On the client side, choose deadlines deliberately. A reasonable default is the longest the caller is willing to wait, set per call, not globally. The lab uses one second for ordinary calls.
Metadata, headers and trailers
Metadata is key-value pairs sent beside the message, like HTTP headers. Use it for things that belong to the call and not to the data: request ids, authentication tokens, tracing context, feature flags.
Client to server. metadata.AppendToOutgoingContext(ctx, "x-request-id", "req-42") returns a context carrying the pairs. In the handler, metadata.FromIncomingContext(ctx) returns them. Keys are lower-cased by the library; values are strings, and keys ending in -bin carry binary values, base64 encoded on the wire.
Server to client comes in two phases. Headers are sent before the response; the handler calls grpc.SetHeader(ctx, md) (or SendHeader to flush immediately). Trailers are sent after the response with the status; the handler calls grpc.SetTrailer(ctx, md). The client reads them by passing grpc.Header(&md) and grpc.Trailer(&md) as call options, as the lab does.
The first output line shows the whole loop: the client sent x-request-id: req-42, the handler echoed it as a header, set x-cache: miss as a trailer, and the client printed both. Headers suit values the client may need early, trailers suit things only known after the work is done, such as timings or counts.
Do not use metadata as a second request body. It is for small, cross-cutting values, and servers limit the total size of metadata (8 KB by default in grpc-go). The metadata and context propagation article shows how to carry values across service hops.
Idempotent writes
Put returns created: true for a new key and false for an update. The response tells the caller what happened without a second call, and the operation is safe to repeat: setting the same value twice leaves the same state. Idempotence matters because clients retry. gRPC's retry policy, set in the service config, only retries codes you name (usually Unavailable), and it is safe only for methods whose effect does not change on repetition (retries and hedging). For a non-idempotent create, accept a client-generated request id and return the original result for a repeat, as in idempotency keys.
Testing unary handlers
You can call the handler method directly in a test, since it is an ordinary Go method: store.Get(ctx, &pb.GetRequest{Key: "x"}), then compare status.Code(err). That tests the logic without a network. For the full path, including interceptors, metadata and deadlines, run a real server over an in-memory connection with bufconn, as in testing gRPC services. Test the unhappy paths: each error code your handler can return, a cancelled context, a deadline that expires and a call with and without the metadata you rely on.
A checklist for each unary method
- Validate the request and return
InvalidArgumentwith a message that names the field. - Use the context for every blocking call.
- Return status errors with accurate codes, and log internal causes.
- Keep responses small and bounded; use streaming or pagination for lists (server streaming).
- Decide idempotence, and document it.
- Document which metadata keys the method reads and writes.
The rules about status codes and deadlines are described in the gRPC project's status codes guide and deadlines guide.


