open atlas
↑ Back to track
Go, zero to senior GO · 09 · 03

Gateways and REST: google.api.http annotations, transcoding limits, gRPC-Web, and the ConnectRPC alternative

grpc-gateway compiles google.api.http annotations into a proxy translating JSON REST to protobuf RPCs — the internal-gRPC, public-REST topology. Streaming and error mapping are rough edges, browsers need a proxy for trailers, ConnectRPC collapses the stack into plain HTTP.

GO Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

The public API gateway died at 14:07, and the gRPC backend behind it barely noticed. A migration had left a lock on one Postgres table, and the orders RPC went from 40ms to 90 seconds. The gateway — generated by grpc-gateway, deployed eight months earlier — forwarded every REST request as a gRPC call with no deadline: nobody had configured one, so each call effectively waited forever. Every incoming request parked a goroutine on a stream that would answer in a minute and a half; at 30 requests per second that is 2,700 new parked goroutines per minute, each pinning request buffers and an HTTP/2 stream. RSS climbed for eleven minutes, then the OOM killer took the gateway out — all endpoints, including the perfectly healthy ones and the health check itself. One slow table had become a full public-API outage, because the translation layer between two timeout cultures — REST clients that hang up, gRPC servers that expect a deadline — had been configured with neither.

Annotations compile into a proxy

Before you write a handcrafted REST adapter for a gRPC service, ask yourself: what happens when the 60th RPC is added? That question is what makes the generated approach worth understanding first.

External consumers will not install protoc. The standard answer keeps gRPC inside and compiles a REST facade from the same contract — google.api.http annotations on each RPC:

import "google/api/annotations.proto";

service Orders {
  rpc GetOrder(GetOrderRequest) returns (Order) {
    option (google.api.http) = { get: "/v1/orders/{order_id}" };
  }
  rpc CreateOrder(CreateOrderRequest) returns (Order) {
    option (google.api.http) = { post: "/v1/orders", body: "*" };
  }
}

The binding rules are mechanical: path template variables like order_id bind to the request message field of the same name; for a GET, every remaining scalar field becomes an accepted query parameter; body: "*" maps the whole JSON body onto the request message for writes. From this, protoc-gen-grpc-gateway generates a reverse proxy — a runtime.ServeMux that is itself just an http.Handler, so everything from the HTTP unit applies: wrap it in middleware, mount it under a path, time it out.

gw := runtime.NewServeMux()
// Dials the gRPC backend; JSON in, protobuf over HTTP/2 out, JSON back.
err := orderspb.RegisterOrdersHandlerFromEndpoint(ctx, gw, "orders:50051", dialOpts)
// The Hook's missing line — never proxy without a budget:
srv := &http.Server{Addr: ":8080", Handler: withTimeout(gw, 5*time.Second)}

Per request the gateway does: decode JSON into the generated request struct, invoke the RPC through a real gRPC client connection (interceptors and client-side balancing included), encode the response back to JSON. There is also an in-process variant — RegisterOrdersHandlerServer — that calls your server implementation directly without dialing; it saves the hop but bypasses client-side interceptors and stats, so most production setups keep the real connection. The same annotations feed protoc-gen-openapiv2, so the public OpenAPI document is generated from the contract instead of drifting away from it.

Quiz

For rpc ListOrders(ListOrdersRequest) annotated with get: /v1/orders, the request message has fields page_size and customer_id. How does a REST client pass them?

What refuses to translate: streams, errors, deadlines

Transcoding is honest about unary calls and evasive about everything else. Server streaming technically works: the gateway emits newline-delimited JSON chunks, each wrapped in a result envelope — not a JSON array, which breaks every client that calls response.json() expecting one. Client and bidi streaming do not map to request/response REST at all; if a browser needs them, that endpoint needs a different design — websockets, gRPC-Web, or Connect — not a gateway annotation.

Errors need a translation table because the two worlds disagree about what a status is. The gateway ships a default mapping — InvalidArgument to 400, NotFound to 404, PermissionDenied to 403, Unavailable to 503, DeadlineExceeded to 504, Internal and Unknown to 500 — and emits the google.rpc.Status JSON as the body. The day your public API needs its own error envelope, you install runtime.WithErrorHandler and own the mapping; do it deliberately once, not endpoint by endpoint.

Deadlines are the Hook. REST clients do not send grpc-timeout; they just hang up, and a hung-up HTTP client does not automatically cancel the upstream RPC unless the request context is wired through — and even then, a never-answering backend holds the goroutine until something bounds it. The gateway honors an inbound Grpc-Timeout header if present (it almost never is, from public clients) and supports a default via runtime.DefaultContextTimeout or plain http.Server/middleware timeouts. The rule that would have saved the Hook: a proxy in front of gRPC must originate deadlines, not just forward them, because its public callers never will.

Why this works

Why generate the proxy from annotations instead of writing a small REST layer by hand? Counting endpoints answers it: at five, handwritten wins on flexibility. At sixty, the handwritten layer is sixty opportunities for drift between REST docs and proto reality, sixty hand-rolled error mappings, and a team that must update three places per field. The generated proxy moves API review into the .proto file — which the previous lesson already gated with buf breaking — and OpenAPI falls out of the same annotations for free.

Topology choices: gateway, gRPC-Web, or Connect

The common production topology is now visible end to end: services speak gRPC to each other — compiled contracts, propagated deadlines, streams — and the public edge runs the generated gateway speaking REST and JSON to people you cannot hand a .proto file to. Internal teams get the strict contract; external consumers get curl, OpenAPI docs, and API-key middleware on a plain http.Handler.

Browsers complicate it. Browser fetch cannot read HTTP/2 trailers — and gRPC puts grpc-status precisely there — so native browser gRPC is impossible regardless of HTTP/2 support. gRPC-Web is the workaround protocol: the status moves into the body or headers, and a proxy (Envoy’s grpc_web filter, or the gateway) translates to real gRPC. It works, at the cost of one more moving piece in every browser path.

ConnectRPC is the honest modern alternative to the whole sandwich: one server speaks three protocols on one port — gRPC for existing internal clients, gRPC-Web for browsers without a proxy, and the Connect protocol, where a unary call is a plain HTTP POST with a JSON body you can curl and errors live in the response body, not trailers. Same .proto, different generated runtime. The tradeoff is ecosystem age: grpc-gateway has a decade of production scar tissue, Connect is younger with a smaller tool universe — but for a new Go system that needs browsers and curl-ability, it deletes the gateway and the Envoy filter from the architecture diagram.

Whichever edge you pick, the protobuf-first flow exerts a quiet design pressure worth naming: annotations force you to phrase every operation as a resource path and a verb, with explicit request and response messages. Teams that start REST-first often skip that modeling and grow verb-soup endpoints; teams that compile REST from a contract get resource discipline imposed by the toolchain.

Quiz

Why can a browser not be a native gRPC client, even over HTTP/2?

Recall before you leave
  1. 01
    Trace a REST call through a grpc-gateway deployment, naming the binding rules and the two translation tables involved.
  2. 02
    Compare grpc-gateway, gRPC-Web, and ConnectRPC as edges for the same Go backend — what does each solve and at what cost?
Recap

The gateway pattern completes the unit’s arc: the same .proto whose tags you version now compiles its own public edge. google.api.http annotations declare per-RPC REST mappings — path variables bind to message fields by name, leftover scalars become query parameters on GETs, and body star maps JSON bodies for writes. protoc-gen-grpc-gateway turns that into a reverse proxy which is just an http.Handler: JSON decodes into the generated request struct, the call crosses a real gRPC client connection, the response encodes back, and protoc-gen-openapiv2 emits public docs from the same annotations so documentation cannot drift from the contract. Transcoding has edges: server streams arrive as newline-delimited result-wrapped JSON rather than an array, client and bidi streaming do not map at all, and errors cross a deliberate table — NotFound to 404, Unavailable to 503, DeadlineExceeded to 504 — overridable once, globally, with WithErrorHandler. The operational lesson costs the most: REST clients never send a timeout, so the gateway must originate deadlines via DefaultContextTimeout or server middleware — an unbounded proxy converts one slow backend table into a goroutine pileup and a full public outage. Browsers add a hard constraint: fetch cannot read the trailers where grpc-status lives, so browser paths need gRPC-Web behind an Envoy filter — or ConnectRPC, which serves gRPC, gRPC-Web, and curl-able Connect JSON from one port and removes the proxy entirely, traded against a younger ecosystem. The standard topology stands: native gRPC inside, a compiled translation at the edge, and resource modeling imposed by the contract itself. Now when you design a service that must serve both internal gRPC clients and a public REST API, you will configure the deadline at the gateway first — before the first external request ever reaches your backend.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.