DevOpsIndex

gRPC Deep Dive

Implementation-level gRPC: wire format, streaming modes with full code, HTTP/2 mechanics, interceptors, deadlines, health checking, load balancing, gRPC-Web, and error handling. Conceptual overview lives in grpc-graphql.md — this file goes underneath it.


1. Protobuf Wire Format Internals

Every field on the wire is a tag-value pair. The tag encodes both the field number and the wire type in a single varint.

tag = (field_number << 3) | wire_type
Wire type ID Used for
Varint 0 int32, int64, uint32, uint64, sint32/64, bool, enum
64-bit 1 fixed64, sfixed64, double
Length-delimited 2 string, bytes, embedded messages, packed repeated fields
Start group / End group 3 / 4 deprecated, unused in proto3
32-bit 5 fixed32, sfixed32, float

Varint encoding

Each byte uses 7 bits for data + 1 continuation bit (MSB). Small numbers cost 1 byte; large numbers cost more.

Encoding 300 as a varint:
300 = 0b100101100

Split into 7-bit groups (LSB first): 0101100  0000010
Set continuation bit on all but the last byte:
  byte 0: 1_0101100  -> 0xAC   (continuation bit set, more bytes follow)
  byte 1: 0_0000010  -> 0x02   (final byte)

Wire bytes: AC 02
message Order {
  string id = 1;        // tag = (1 << 3) | 2 = 0x0A (length-delimited)
  double amount = 3;     // tag = (3 << 3) | 1 = 0x19 (64-bit)
  int32 quantity = 4;    // tag = (4 << 3) | 0 = 0x20 (varint)
}

Encoding Order{id: "A1", amount: 9.5, quantity: 300}:

0A 02 41 31          # tag=0x0A (field 1, len-delim), length=2, bytes "A1"
19 00 00 00 00 00 00 23 40   # tag=0x19 (field 3, 64-bit), IEEE754 double 9.5
20 AC 02             # tag=0x20 (field 4, varint), varint(300) = AC 02

Why this is smaller than JSON: no field names on the wire (just numeric tags), no delimiters/whitespace, numbers use variable-length encoding instead of ASCII digits.

Message size prefixing over HTTP/2

gRPC frames each message with a 5-byte prefix before handing it to HTTP/2 DATA frames — this is the Length-Prefixed Message format defined by the gRPC wire protocol, independent of protobuf itself:

┌─────────────┬───────────────────────┬─────────────────────┐
│ Compressed  │ Message Length        │ Message (protobuf   │
│ Flag (1B)   │ (4B, big-endian uint) │ bytes, N bytes)      │
└─────────────┴───────────────────────┴─────────────────────┘

This lets a receiver read exactly N bytes for one message even when several messages arrive back-to-back inside a single HTTP/2 DATA frame (frames don't align 1:1 with RPC messages — a large message can span multiple DATA frames, or several small messages can pack into one).

HTTP/2 DATA frame payload for a streamed response:
[00][00 00 00 0C][... 12 bytes of Order protobuf ...][00][00 00 00 08][... 8 bytes ...]
 ^compressed=no  ^length=12                            ^flag        ^length=8

2. The 4 Streaming Modes — Full Proto + Go Code

syntax = "proto3";
package order.v1;
option go_package = "example.com/order/v1;orderv1";

service OrderService {
  rpc GetOrder(GetOrderRequest) returns (Order);                         // unary
  rpc ListOrders(ListOrdersRequest) returns (stream Order);              // server streaming
  rpc BulkCreate(stream CreateOrderRequest) returns (BulkCreateResponse); // client streaming
  rpc Chat(stream ChatMessage) returns (stream ChatMessage);             // bidi streaming
}

message GetOrderRequest { string order_id = 1; }
message ListOrdersRequest { string customer_id = 1; }
message CreateOrderRequest { string customer_id = 1; double amount = 2; }
message BulkCreateResponse { int32 created_count = 1; repeated string order_ids = 2; }
message ChatMessage { string from = 1; string text = 2; }

message Order {
  string id = 1;
  string customer_id = 2;
  double amount = 3;
}

2.1 Unary — one request, one response

// Server
func (s *server) GetOrder(ctx context.Context, req *orderv1.GetOrderRequest) (*orderv1.Order, error) {
    order, ok := s.db[req.OrderId]
    if !ok {
        return nil, status.Errorf(codes.NotFound, "order %s not found", req.OrderId)
    }
    return order, nil
}

// Client
resp, err := client.GetOrder(ctx, &orderv1.GetOrderRequest{OrderId: "abc123"})
if err != nil {
    st, _ := status.FromError(err)
    log.Printf("code=%s msg=%s", st.Code(), st.Message())
    return
}
fmt.Println(resp)

2.2 Server streaming — one request, stream of responses

// Server
func (s *server) ListOrders(req *orderv1.ListOrdersRequest, stream orderv1.OrderService_ListOrdersServer) error {
    for _, order := range s.ordersFor(req.CustomerId) {
        if err := stream.Send(order); err != nil {
            return err // client disconnected or context cancelled
        }
    }
    return nil // returning nil closes the stream with status OK
}

// Client
stream, err := client.ListOrders(ctx, &orderv1.ListOrdersRequest{CustomerId: "cust-1"})
if err != nil {
    log.Fatal(err)
}
for {
    order, err := stream.Recv()
    if err == io.EOF {
        break // server closed the stream normally
    }
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(order)
}

2.3 Client streaming — stream of requests, one response

// Server
func (s *server) BulkCreate(stream orderv1.OrderService_BulkCreateServer) error {
    var ids []string
    for {
        req, err := stream.Recv()
        if err == io.EOF {
            // client finished sending; send the single aggregate response
            return stream.SendAndClose(&orderv1.BulkCreateResponse{
                CreatedCount: int32(len(ids)),
                OrderIds:     ids,
            })
        }
        if err != nil {
            return err
        }
        id := s.create(req)
        ids = append(ids, id)
    }
}

// Client
stream, err := client.BulkCreate(ctx)
if err != nil {
    log.Fatal(err)
}
for _, order := range pendingOrders {
    if err := stream.Send(order); err != nil {
        log.Fatal(err)
    }
}
resp, err := stream.CloseAndRecv() // signals EOF to server, waits for the aggregate response
if err != nil {
    log.Fatal(err)
}
fmt.Println("created:", resp.CreatedCount)

2.4 Bidirectional streaming — full runnable example with context cancellation

// server.go
package main

import (
    "context"
    "io"
    "log"
    "net"

    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"

    orderv1 "example.com/order/v1"
)

type chatServer struct {
    orderv1.UnimplementedOrderServiceServer
}

func (s *chatServer) Chat(stream orderv1.OrderService_ChatServer) error {
    ctx := stream.Context()

    // Independent goroutine reads incoming messages, feeds a channel
    incoming := make(chan *orderv1.ChatMessage)
    errCh := make(chan error, 1)

    go func() {
        for {
            msg, err := stream.Recv()
            if err == io.EOF {
                close(incoming)
                return
            }
            if err != nil {
                errCh <- err
                return
            }
            incoming <- msg
        }
    }()

    for {
        select {
        case <-ctx.Done():
            // Client disconnected, deadline exceeded, or server shutting down
            log.Printf("chat stream ended: %v", ctx.Err())
            return status.Error(codes.Canceled, "stream cancelled")

        case err := <-errCh:
            return err

        case msg, ok := <-incoming:
            if !ok {
                return nil // client closed send side cleanly, end the RPC
            }
            reply := &orderv1.ChatMessage{From: "server", Text: "echo: " + msg.Text}
            if err := stream.Send(reply); err != nil {
                return err
            }
        }
    }
}

func main() {
    lis, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatal(err)
    }
    srv := grpc.NewServer()
    orderv1.RegisterOrderServiceServer(srv, &chatServer{})
    log.Println("listening on :50051")
    log.Fatal(srv.Serve(lis))
}
// client.go
package main

import (
    "context"
    "io"
    "log"
    "time"

    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"

    orderv1 "example.com/order/v1"
)

func main() {
    conn, err := grpc.NewClient("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close()

    client := orderv1.NewOrderServiceClient(conn)

    // Deadline propagates as the grpc-timeout header, cancels the whole call chain
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    stream, err := client.Chat(ctx)
    if err != nil {
        log.Fatal(err)
    }

    // Send goroutine
    go func() {
        messages := []string{"hello", "how are you", "goodbye"}
        for _, text := range messages {
            if err := stream.Send(&orderv1.ChatMessage{From: "client", Text: text}); err != nil {
                log.Printf("send error: %v", err)
                return
            }
            time.Sleep(time.Second)
        }
        stream.CloseSend() // half-close: no more sends, server can still reply
    }()

    // Receive loop on the main goroutine
    for {
        msg, err := stream.Recv()
        if err == io.EOF {
            log.Println("stream closed by server")
            return
        }
        if err != nil {
            log.Printf("recv error: %v (ctx err: %v)", err, ctx.Err())
            return
        }
        log.Printf("received: %s", msg.Text)
    }
}

Cancellation propagation: cancelling ctx (timeout, explicit cancel(), or client process exit) tears down the underlying HTTP/2 stream — the server's stream.Context().Done() fires, and any blocked stream.Recv()/stream.Send() on both sides unblocks with an error. There is no leaked goroutine as long as both sides select on ctx.Done() as shown above.


3. gRPC over HTTP/2 Mechanics

graph TD
    classDef blue fill:#3498db,stroke:#2980b9,color:#fff
    classDef purple fill:#9b59b6,stroke:#8e44ad,color:#fff
    classDef orange fill:#e67e22,stroke:#d35400,color:#fff

    CONN["Single TCP connection<br/>(one HTTP/2 connection)"]:::blue
    CONN --> S1["Stream 1: RPC call A<br/>HEADERS + DATA + trailing HEADERS"]:::purple
    CONN --> S2["Stream 3: RPC call B<br/>concurrent, independent"]:::purple
    CONN --> S3["Stream 5: streaming RPC C<br/>long-lived, many DATA frames"]:::orange

Each RPC call is one HTTP/2 stream (odd-numbered, client-initiated). Multiple RPCs multiplex over a single TCP connection with no head-of-line blocking between streams — this is the mechanism, not an add-on.

Why gRPC requires HTTP/2, not HTTP/1.1:

Requirement HTTP/1.1 HTTP/2
Full-duplex streaming on one connection Not possible — one request/response per connection at a time (or pipelining, which is broken/unused) Native — independent streams carry concurrent request/response bodies
Trailing metadata (grpc-status, grpc-message sent after the body) No trailer support in practice HEADERS frame can appear after DATA frames (trailers)
Multiplexing without head-of-line blocking No — sequential or multiple TCP connections needed Yes — frames from different streams interleave on one connection
Binary framing Text-based, requires parsing line-by-line Binary frames, structured length-prefixed

gRPC's status is sent as HTTP/2 trailers — a second HEADERS frame after the DATA frames, containing grpc-status and grpc-message. This lets the server stream a full response body and only decide/report final status afterward — impossible in HTTP/1.1, which has no trailer mechanism outside of chunked encoding (which is barely supported and never used this way).

Header compression (HPACK): gRPC calls carry repetitive headers on every request (content-type: application/grpc, grpc-timeout, grpc-encoding, auth tokens). HTTP/2's HPACK maintains a per-connection dynamic table so repeated header values are sent as small index references after the first occurrence instead of full strings every time — meaningful savings at high RPC rates where header overhead would otherwise dominate small messages.

Stream lifecycle for one unary RPC:
HEADERS (:method POST, :path /order.v1.OrderService/GetOrder, grpc-timeout: 5S, ...)
DATA (length-prefixed protobuf request)
DATA END_STREAM=false  -- client's half of the stream ends implicitly after last DATA
  ... server processes ...
HEADERS (:status 200, content-type: application/grpc)
DATA (length-prefixed protobuf response)
HEADERS END_STREAM=true (trailers: grpc-status: 0, grpc-message: "")

4. gRPC Interceptors

Interceptors wrap RPC handling for cross-cutting concerns — the gRPC equivalent of HTTP middleware.

4.1 Unary auth interceptor

package interceptors

import (
    "context"

    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/metadata"
    "google.golang.org/grpc/status"
)

func AuthUnaryInterceptor(validTokens map[string]bool) grpc.UnaryServerInterceptor {
    return func(
        ctx context.Context,
        req interface{},
        info *grpc.UnaryServerInfo,
        handler grpc.UnaryHandler,
    ) (interface{}, error) {
        md, ok := metadata.FromIncomingContext(ctx)
        if !ok {
            return nil, status.Error(codes.Unauthenticated, "missing metadata")
        }

        tokens := md.Get("authorization")
        if len(tokens) == 0 {
            return nil, status.Error(codes.Unauthenticated, "missing authorization header")
        }

        if !validTokens[tokens[0]] {
            return nil, status.Error(codes.PermissionDenied, "invalid token")
        }

        // Attach validated identity into context for downstream handlers
        ctx = context.WithValue(ctx, "authenticated", true)
        return handler(ctx, req) // proceed to the actual RPC method
    }
}

4.2 Prometheus metrics interceptor (unary + stream)

package interceptors

import (
    "context"
    "time"

    "github.com/prometheus/client_golang/prometheus"
    "google.golang.org/grpc"
    "google.golang.org/grpc/status"
)

var (
    rpcDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
        Name:    "grpc_server_handling_seconds",
        Help:    "Response latency for gRPC calls",
        Buckets: prometheus.DefBuckets,
    }, []string{"grpc_method", "grpc_code"})

    rpcInFlight = prometheus.NewGaugeVec(prometheus.GaugeOpts{
        Name: "grpc_server_in_flight_requests",
        Help: "Number of in-flight gRPC requests",
    }, []string{"grpc_method"})
)

func init() {
    prometheus.MustRegister(rpcDuration, rpcInFlight)
}

func MetricsUnaryInterceptor() grpc.UnaryServerInterceptor {
    return func(
        ctx context.Context,
        req interface{},
        info *grpc.UnaryServerInfo,
        handler grpc.UnaryHandler,
    ) (interface{}, error) {
        rpcInFlight.WithLabelValues(info.FullMethod).Inc()
        defer rpcInFlight.WithLabelValues(info.FullMethod).Dec()

        start := time.Now()
        resp, err := handler(ctx, req)
        code := status.Code(err)

        rpcDuration.WithLabelValues(info.FullMethod, code.String()).Observe(time.Since(start).Seconds())
        return resp, err
    }
}

// Stream interceptor wraps the ServerStream to intercept per-message calls or just measure total call duration
func MetricsStreamInterceptor() grpc.StreamServerInterceptor {
    return func(
        srv interface{},
        ss grpc.ServerStream,
        info *grpc.StreamServerInfo,
        handler grpc.StreamHandler,
    ) error {
        rpcInFlight.WithLabelValues(info.FullMethod).Inc()
        defer rpcInFlight.WithLabelValues(info.FullMethod).Dec()

        start := time.Now()
        err := handler(srv, ss)
        code := status.Code(err)

        rpcDuration.WithLabelValues(info.FullMethod, code.String()).Observe(time.Since(start).Seconds())
        return err
    }
}
// Wiring both into the server
srv := grpc.NewServer(
    grpc.ChainUnaryInterceptor(
        interceptors.AuthUnaryInterceptor(validTokens),
        interceptors.MetricsUnaryInterceptor(),
    ),
    grpc.ChainStreamInterceptor(
        interceptors.MetricsStreamInterceptor(),
    ),
)

Interceptor execution order with ChainUnaryInterceptor is left-to-right on the way in (auth runs before metrics, so unauthenticated calls don't pollute latency histograms) and right-to-left unwinding on the way out.


5. Deadline Propagation

A Go context.Context deadline serializes to the grpc-timeout request header on the wire. This is what makes deadlines cascade automatically across a call chain without manual bookkeeping.

sequenceDiagram
    participant A as Service A (gateway)
    participant B as Service B (order)
    participant C as Service C (inventory)

    A->>A: ctx, cancel := context.WithTimeout(ctx, 5s)
    A->>B: gRPC call, header: grpc-timeout: 5000m (5s remaining)
    Note over B: 1.2s elapsed processing so far
    B->>C: forwards ctx (derived), header: grpc-timeout: 3800m (~3.8s remaining)
    Note over C: C only gets what's left of A's original budget, not a fresh 5s
    C-->>B: response (or DEADLINE_EXCEEDED if it took too long)
    B-->>A: response (or propagated DEADLINE_EXCEEDED)
// Service A — sets the top-level budget
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := serviceBClient.PlaceOrder(ctx, req)

// Service B — MUST forward the same ctx (or a derived one), not create a fresh context
func (s *server) PlaceOrder(ctx context.Context, req *PlaceOrderRequest) (*PlaceOrderResponse, error) {
    // ctx here already carries A's remaining deadline, minus network + processing time so far
    invResp, err := s.inventoryClient.CheckStock(ctx, &CheckStockRequest{Sku: req.Sku})
    if err != nil {
        if status.Code(err) == codes.DeadlineExceeded {
            return nil, status.Error(codes.DeadlineExceeded, "upstream inventory check timed out")
        }
        return nil, err
    }
    // ...
}

Cascading behavior: if Service B creates a new independent context.WithTimeout(context.Background(), 5*time.Second) instead of deriving from the incoming ctx, it silently breaks the budget — C could get a fresh 5s even though A's original 5s is nearly exhausted, causing A to time out and abandon the call while B and C keep working. Always derive downstream contexts from the incoming one:

// WRONG — resets the deadline, breaks cascading
ctx2, cancel := context.WithTimeout(context.Background(), 5*time.Second)

// CORRECT — inherits and can only shrink the remaining deadline, never extend it
ctx2, cancel := context.WithTimeout(ctx, 2*time.Second) // capped at min(2s, ctx's remaining time)

grpc-timeout header format is a number + unit suffix: H (hours), M (minutes), S (seconds), m (milliseconds), u (microseconds), n (nanoseconds) — e.g. 5000m = 5000 milliseconds.


6. gRPC Health Checking Protocol

Standard proto (grpc.health.v1.Health) — not something you invent per-service:

// grpc/health/v1/health.proto (part of grpc-proto, well-known)
service Health {
  rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
  rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
}

message HealthCheckRequest {
  string service = 1; // empty string = overall server health
}

message HealthCheckResponse {
  enum ServingStatus {
    UNKNOWN = 0;
    SERVING = 1;
    NOT_SERVING = 2;
    SERVICE_UNKNOWN = 3;
  }
  ServingStatus status = 1;
}
import "google.golang.org/grpc/health"
import healthpb "google.golang.org/grpc/health/grpc_health_v1"

healthServer := health.NewServer()
healthpb.RegisterHealthServer(grpcServer, healthServer)

// Liveness: process is alive at all — set once at startup
healthServer.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)

// Readiness: per-dependency granularity — flip when a specific downstream is unavailable
healthServer.SetServingStatus("order.v1.OrderService", healthpb.HealthCheckResponse_NOT_SERVING)
Semantic Maps to Behavior
Liveness Check("") — overall server status K8s liveness probe uses this: if NOT_SERVING, container gets restarted
Readiness Check("<specific-service>") K8s readiness probe: if NOT_SERVING, pod removed from Service endpoints but not restarted
Watch Streaming variant Client-side load balancers subscribe instead of polling Check repeatedly
# Kubernetes probe using grpc_health_probe binary (or native grpc probe in K8s 1.24+)
livenessProbe:
  grpc:
    port: 50051
readinessProbe:
  grpc:
    port: 50051
    service: "order.v1.OrderService"
# Manual check with grpcurl
grpcurl -plaintext localhost:50051 grpc.health.v1.Health/Check
grpcurl -plaintext -d '{"service": "order.v1.OrderService"}' localhost:50051 grpc.health.v1.Health/Check

7. gRPC Load Balancing

Client-side load balancing modes

graph LR
    classDef blue fill:#3498db,stroke:#2980b9,color:#fff
    classDef orange fill:#e67e22,stroke:#d35400,color:#fff

    C1["Client (pick_first)"]:::blue -->|"all RPCs"| S1["Backend 1<br/>(sticky — one conn)"]:::orange
    C2["Client (round_robin)"]:::blue -->|"RPC 1,4,7"| S1b["Backend 1"]:::orange
    C2 -->|"RPC 2,5,8"| S2b["Backend 2"]:::orange
    C2 -->|"RPC 3,6,9"| S3b["Backend 3"]:::orange
Policy Behavior Default?
pick_first Connects to the first resolved address, sends everything there until it fails Yes, gRPC's default
round_robin Opens a connection to every resolved backend, distributes RPCs round-robin across all of them Opt-in via service config
conn, err := grpc.NewClient(
    "dns:///order-service.internal:50051",
    grpc.WithDefaultServiceConfig(`{"loadBalancingConfig": [{"round_robin":{}}]}`),
    grpc.WithTransportCredentials(insecure.NewCredentials()),
)

Why gRPC load balancing differs from HTTP/1.1 — and why L4 LBs fail here

An L4 (TCP-level) load balancer distributes connections, not requests. HTTP/1.1 typically opens many short-lived connections, so L4 balancing naturally spreads load. gRPC deliberately reuses a single long-lived HTTP/2 connection for many multiplexed RPCs — an L4 LB balances that one connection to one backend, and every RPC on it goes to the same backend for the connection's lifetime. Ten gRPC clients each holding one persistent connection to a 3-backend L4-balanced target commonly produces wildly uneven load (e.g., all 10 pinned to 1-2 backends) rather than an even 3-way split.

L4 LB (e.g. plain TCP NLB) + gRPC == load concentrates on whichever backend
each client's long-lived connection happened to land on. New backends added
to the pool get ~0 traffic until existing connections cycle.

Solutions, in increasing sophistication:

Approach Mechanism Tradeoff
Client-side round_robin + DNS resolver Client opens a connection per resolved backend IP, spreads RPCs itself Requires client cooperation; DNS TTL/refresh lag on backend set changes
gRPC-aware L7 LB (e.g. Envoy, Linkerd) Proxy terminates HTTP/2 from client, re-multiplexes RPCs across backend connections it manages Proxy hop adds latency; needs sidecar or dedicated proxy tier
xDS (client-side, via Envoy's control plane API) Client speaks xDS to a control plane, gets live backend endpoint updates, load balances itself without a proxy in the data path No extra hop, but requires xDS-capable client stack (gRPC's built-in xDS resolver, or Istio/Envoy-integrated clients)

xDS is the production answer at scale — it gives client-side load balancing (no extra proxy hop, no L4-connection-pinning problem) while still getting centrally-managed, dynamically-updated backend membership the way a proxy-based LB would.


8. gRPC-Web

Browsers cannot originate raw gRPC calls: no browser JS API can set HTTP/2 trailers, control frame-level flow control, or send arbitrary binary frames outside of fetch/XHR's constraints — and browsers don't expose trailer read access at all, which gRPC's status reporting depends on.

flowchart LR
    classDef blue fill:#3498db,stroke:#2980b9,color:#fff
    classDef orange fill:#e67e22,stroke:#d35400,color:#fff
    classDef purple fill:#9b59b6,stroke:#8e44ad,color:#fff

    BROWSER["Browser<br/>grpc-web client lib"]:::blue -->|"HTTP/1.1 or HTTP/2<br/>base64 or binary,<br/>no trailers needed"| PROXY["Envoy / grpc-web proxy<br/>translates framing"]:::purple
    PROXY -->|"real gRPC (HTTP/2 + trailers)"| BACKEND["gRPC backend service"]:::orange

The grpc-web wire format moves trailers (grpc-status, grpc-message) into the message body stream itself (as a special trailer frame appended after the last data frame) instead of relying on HTTP/2 trailers — something a browser fetch response body can actually deliver. A translating proxy (Envoy's grpc_web filter, or a dedicated grpcwebproxy) converts between this browser-safe framing and real gRPC on the backend side.

# Envoy grpc-web filter snippet
http_filters:
  - name: envoy.filters.http.grpc_web
  - name: envoy.filters.http.cors
  - name: envoy.filters.http.router
// Browser client using grpc-web generated stubs
import { OrderServiceClient } from "./order_v1_grpc_web_pb";
const client = new OrderServiceClient("https://api.example.com"); // hits the Envoy proxy, not the backend directly

client.getOrder(request, {}, (err, response) => {
  if (err) { console.error(err.code, err.message); return; }
  console.log(response.toObject());
});

Note: gRPC-Web does not support client-streaming or bidirectional streaming in browsers (no way to half-close a request stream over fetch) — only unary and server-streaming work end-to-end.


9. Error Handling

Status code mapping vs HTTP

gRPC code Numeric Closest HTTP equivalent Meaning
OK 0 200 Success
CANCELLED 1 499 (nginx convention) Client cancelled the call
UNKNOWN 2 500 Unhandled server exception
INVALID_ARGUMENT 3 400 Malformed request
DEADLINE_EXCEEDED 4 504 Timed out
NOT_FOUND 5 404 Resource missing
ALREADY_EXISTS 6 409 Conflict
PERMISSION_DENIED 7 403 Authenticated, not authorized
RESOURCE_EXHAUSTED 8 429 Rate limited / quota exceeded
FAILED_PRECONDITION 9 400/409 System not in a state the request requires
ABORTED 10 409 Concurrency conflict (e.g. optimistic lock failure)
OUT_OF_RANGE 11 400 Value outside valid range
UNIMPLEMENTED 12 501 Method not implemented by server
INTERNAL 13 500 Server-side invariant violation
UNAVAILABLE 14 503 Server down — safe to retry
DATA_LOSS 15 500 Unrecoverable data loss/corruption
UNAUTHENTICATED 16 401 No/invalid credentials

Rich error details via google.rpc.Status

A bare gRPC status code + string message is often not enough — google.rpc.Status lets you attach structured, typed detail messages (field violations, retry hints, quota info) that clients can programmatically parse instead of string-matching error messages.

import "google/rpc/error_details.proto";

// Server constructs a status with typed details:
import (
    "google.golang.org/genproto/googleapis/rpc/errdetails"
    "google.golang.org/grpc/status"
    "google.golang.org/grpc/codes"
)

func (s *server) CreateOrder(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
    if req.Amount <= 0 {
        st := status.New(codes.InvalidArgument, "invalid order")
        detail := &errdetails.BadRequest{
            FieldViolations: []*errdetails.BadRequest_FieldViolation{
                {Field: "amount", Description: "must be greater than zero"},
            },
        }
        stWithDetails, err := st.WithDetails(detail)
        if err != nil {
            return nil, st.Err() // fall back to plain status if attaching details fails
        }
        return nil, stWithDetails.Err()
    }
    // ...
}
// Client extracts structured details instead of parsing message strings
resp, err := client.CreateOrder(ctx, req)
if err != nil {
    st := status.Convert(err)
    for _, detail := range st.Details() {
        if br, ok := detail.(*errdetails.BadRequest); ok {
            for _, v := range br.FieldViolations {
                log.Printf("field=%s issue=%s", v.Field, v.Description)
            }
        }
    }
}

Other common google.rpc detail types: RetryInfo (how long to back off), QuotaFailure (which quota was exceeded), DebugInfo (stack trace, internal-only), PreconditionFailure, ResourceInfo. Prefer these over encoding structured data into the plain message string — they survive serialization across languages consistently since they're just protobuf messages.