This episode covers the real implementation of the four RPC patterns: unary, server streaming, client streaming, and bidirectional streaming. You'll also learn error handling with status codes, rich error details, and handling deadlines and cancellation on both sides.

The contract has been generated and the first server is running. Now it's time to complete gRPC's core capabilities: the four RPC patterns and proper error handling. These two things separate a gRPC application that merely runs from one that's production-ready.
In episode 5 you'll implement unary, server streaming, client streaming, and bidirectional streaming in a single service. After that, we cover status codes, rich error details, and deadlines and cancellation — topics that will keep coming up through the end of the series.
Unary is the one-request-one-response pattern. This is what you already wrote in episode 4, and it remains the most common pattern for operations like reading a single entity:
func (s *catalogServer) GetProduct(
ctx context.Context, in *pb.ProductId,
) (*pb.Product, error) {
if in.Id == "" {
return nil, status.Error(codes.InvalidArgument, "id wajib diisi")
}
return s.db.Find(in.Id)
}Notice status.Error(codes.InvalidArgument, "id wajib diisi"): the server returns a standard status code, not just a string. The client can read this code programmatically to make decisions.
Server streaming sends many responses for a single request — perfect for large lists that aren't practical to send all at once:
func (s *catalogServer) ListProducts(
in *pb.ProductQuery, stream pb.CatalogService_ListProductsServer,
) error {
products, err := s.db.Search(in.Keyword)
if err != nil {
return err
}
for _, p := range products {
if err := stream.Send(p); err != nil {
return err
}
}
return nil
}The client reads results one by one via stream.Recv() until it gets io.EOF. The stream.Send(p) method streams one message per iteration without waiting for all the data to be collected — memory stays under control even for lists of millions of rows.
Client streaming flips the direction: the client sends many messages, and the server replies with a single message at the end. Useful for uploads or batch aggregation:
func (s *catalogServer) AddBulk(
stream pb.CatalogService_AddBulkServer,
) error {
var count int32
for {
p, err := stream.Recv()
if err == io.EOF {
return stream.SendAndClose(&pb.BulkResult{AddedCount: count})
}
if err != nil {
return err
}
s.db.Upsert(p)
count++
}
}The loop ends when stream.Recv() == io.EOF, signaling that the client is done sending. Then the server returns a single summary via SendAndClose.
The strongest pattern: both sides send and receive simultaneously. Ideal for chat, voice, or real-time stream processing:
func (s *catalogServer) Search(
stream pb.CatalogService_SearchServer,
) error {
for {
term, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
for _, hit := range s.db.Search(term.Keyword) {
if err := stream.Send(hit); err != nil {
return err
}
}
}
}On the client, stream.Send and stream.Recv can be called concurrently from different goroutines. stream.Send(hit) inside the same loop is still safe because the flow is sequential within a single goroutine.
Status codes give a structured signal; rich error details add context. The gRPC standard uses google/rpc/error_details.proto:
st := status.New(codes.OutOfRange, "kuota produk terlampaui")
ds, _ := st.WithDetails(
&errdetails.QuotaFailure{Violations: []*errdetails.QuotaFailure_Violation{
{Subject: "product:add", Description: "melebihi 1000 item"},
}},
)
return nil, ds.Err()The client reads status.FromError(err) then calls status.Convert(err) to extract the details. &errdetails.QuotaFailure{...} gives machines (and humans) context that can be parsed.
On the client side, the deadline is set via context:
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
defer cancel()On the server side, check for cancellation so work isn't continued in vain:
select {
case <-ctx.Done():
return nil, status.FromContextError(ctx.Err()).Err()
default:
}When the deadline is exceeded, the client receives DEADLINE_EXCEEDED and cancellation propagates through the entire call chain. status.FromContextError(ctx.Err()) translates the context error into the correct gRPC status code.
Key takeaways:
io.EOF marks the end of a stream from the sender's side.In episode 6 next, we cover metadata, interceptors, and lifecycle callbacks — how to send authentication tokens via metadata, build interceptors on the client and server side for both unary and streaming, and lifecycle callbacks for request logging, tracing, and authorization context. These patterns turn your error handling into reusable infrastructure.