gw-02 — The Hitchhiker's Guide to L7 Protocols
Companion to CONCEPTS.md, with the runnable HTTP/2 toolkit in
src/go/h2/. You will decode the same bytesnghttp -vshows — but from first principles, so you can debug an h2 problem nobody else on the team can.
L7 is where a proxy stops being a dumb pipe (gw-01) and starts understanding traffic: routing per request, retrying, rewriting, observing. The price is that you must parse the protocol, and HTTP/2 is where most engineers' mental model is fuzziest. This guide makes it concrete by building the three mechanisms that make h2 different — multiplexed binary framing, HPACK header compression, and flow control — and then connecting them to the real-world traps: the gRPC load-balancing pitfall and HTTP/3.
1. The frame layer (frame.go)
Everything in h2 is a frame: a 9-byte header + payload.
+-----------------------------------------------+
| Length (24) |
+---------------+---------------+---------------+
| Type (8) | Flags (8) |
+-+-------------+---------------+---------------+
|R| Stream Identifier (31) |
+=+=============================================+
| Frame Payload (Length bytes) ...
ReadFrame is a faithful decode: read 9 bytes, pull the 24-bit length,
the type, the flags, and the 31-bit stream id (masking the reserved
high bit — forgetting that mask is a classic bug that yields
astronomical stream IDs). WriteFrame is the inverse, so the package
round-trips (see TestFrameRoundTrip).
The teachable property is multiplexing: frames for many streams
interleave on one connection, demultiplexed by StreamID.
TestReadFramesMultiplex writes HEADERS for stream 1, HEADERS for
stream 3, then DATA for each — interleaved — and reads them back in
order. One TCP connection, two concurrent requests. That is the entire
reason h2 killed the h1 connection-pool churn problem (and why gw-04's
churn work leans on h2 to the origin).
Frame types to know by heart: HEADERS (start a request/response),
DATA (body), SETTINGS (startup config), WINDOW_UPDATE (flow-control
credit), RST_STREAM (cancel one stream), GOAWAY (graceful connection
drain — the h2 equivalent of gw-01's connection drain, but in-band), and
CONTINUATION (header block spillover — and the vector for the
"Rapid Reset"/CONTINUATION-flood DoS class).
MaxFrameLen guards the 24-bit length so a hostile peer can't make you
allocate gigabytes — the kind of defensive detail a maintainer adds
after the first fuzzing report.
2. HPACK (hpack.go) — the stateful one
Headers repeat enormously (:authority, user-agent, cookies on every
request). HPACK compresses them with two tables:
- a static table of 61 common header fields (RFC 7541 Appendix A),
fully reproduced in
staticTable; - a per-connection dynamic table that both sides build as they talk: the first time you send a header it's spelled out and remembered; after that it's a one-byte index.
Integer coding (the part everyone gets subtly wrong)
HPACK integers use an N-bit prefix then continuation bytes (§5.1).
EncodeInt/decodeInt implement it; TestEncodeDecodeInt checks the
RFC §C.1 vectors exactly: 10 in a 5-bit prefix is 0x0a; 1337 is
0x1f 0x9a 0x0a; 42 in an 8-bit prefix is 0x2a. The continuation-
byte loop has an overflow guard — an unbounded integer is a DoS.
The dynamic table, proven against the RFC
TestHpackRFCRequestExamples decodes the actual RFC 7541 §C.3.1 and
§C.3.2 request blocks with one Decoder instance. The second
request uses byte 0xbe — an index into the dynamic table — to refer to
the :authority: www.example.com that the first request added. If the
decoder didn't persist its table across HEADERS frames, this would
decode to garbage. That is the single most important HPACK fact for a
proxy engineer:
The dynamic table is per-connection shared state between the encoder and decoder. If your gateway rewrites a header (adds
x-forwarded-for, strips a hop-by-hop header), it must re-encode with its OWN encoder whose table tracks what it actually sent to the origin — never blindly forward the client's HPACK block. Desync the table and every subsequent header on that connection corrupts.
TestHpackDynamicEviction proves the size accounting and eviction
(each entry costs len(name)+len(value)+32 bytes; over maxSize the
oldest entries are evicted). Bounding maxSize is your defense against
an HPACK-table-bloat DoS.
Huffman, honestly
Real captures Huffman-code header strings. This lab decoder handles
integers, literals, and the dynamic table — everything that teaches the
mechanics — and surfaces Huffman explicitly (ErrHuffman,
TestHpackHuffmanSurfaced) rather than guessing. For production header
parsing you use golang.org/x/net/http2/hpack, which includes the
257-symbol Huffman table. The lesson here is the table mechanics; the
Huffman codec is a (large, mechanical) lookup you should know exists,
not re-implement under interview pressure.
3. Flow control (flow.go) — backpressure, again
h2 adds application-level flow control on top of TCP's: two credit
windows, one per stream and one per connection. A sender may transmit
DATA only while both are positive; each byte debits both; a
WINDOW_UPDATE credits one.
TestFlowControl walks the subtle part: drain a stream to zero and a
WINDOW_UPDATE on the stream alone is not enough if the
connection window is also zero — you must credit both. That two-level
model is why a single greedy stream can't starve the others, and why a
misconfigured SETTINGS_INITIAL_WINDOW_SIZE is a throughput bug on
high-BDP (bandwidth × delay) links: the window caps in-flight bytes ≈
throughput × RTT, so a too-small window throttles a fast, far link.
This is the same backpressure principle as gw-01's coupled copy, but
expressed as explicit credits in the protocol instead of a blocking
Write. Recognizing that "flow control" at L4 (TCP receive window), L7
(h2 WINDOW_UPDATE), and in your proxy's buffers (gw-01) are the same
idea at three layers is a senior insight.
4. The demux (demux.go) — multiplexing made visible
Demux reconstructs per-stream state from an interleaved frame stream:
stream phase (idle → open → half-closed → closed), header count, data
bytes, and flow-control debits. TestDemuxMultiplexing feeds HEADERS+
DATA for streams 1 and 3 and asserts ActiveStreams() == [1, 3] — two
requests concurrently in flight on one connection — and that an
END_STREAM flag moves a stream to half-closed.
The CLI h2inspect wraps this: pipe it a
raw h2 byte stream and it prints a frame trace + decoded headers +
the multiplexed stream set, the same view as nghttp -v but from your
own parser.
5. The gRPC load-balancing trap (the interview favorite)
gRPC is just HTTP/2 with conventions: :path = /package.Service/Method,
content-type: application/grpc, length-prefixed protobuf in DATA
frames, and — critically — status in trailers (a second HEADERS
frame after the body carrying grpc-status). So HTTP 200 +
grpc-status 14 is a failed call. A gateway that classifies success by
:status alone reports a broken backend as healthy (this matters for
gw-06 retries and gw-11 SLOs).
The trap: h2 multiplexes all calls over one long-lived connection,
so a connection-level (L4, gw-01) load balancer pins every call to one
backend. You need L7/per-request balancing (gw-06), or client-side LB
with one subchannel per backend, or MAX_CONNECTION_AGE to force
periodic reconnects — which reintroduces exactly the connection churn
gw-04 fights. steps/03 reproduces
the skew. Be ready to draw this on a whiteboard.
6. HTTP/3 in one breath
h2 fixed h1's application-level head-of-line blocking but not the transport-level kind: it's all one TCP connection, so one lost segment stalls every stream until retransmit. HTTP/3 moves the transport into userspace over UDP (QUIC): independent per-stream loss recovery (no TCP HOL), TLS 1.3 folded into the handshake (1-RTT, 0-RTT resume), and connection migration (a QUIC Connection ID, not the 4-tuple, identifies the connection — so a phone switching Wi-Fi→cellular keeps its connection). For the edge this is huge for mobile (Netflix devices) and painful for L4 LBs that hash the 4-tuple. Header compression becomes QPACK (HOL-blocking-safe). You won't implement QUIC here, but you must be able to say why it exists: to kill the residual TCP HOL that h2's single connection couldn't.
7. Hands-on
cd src/go
bash ../scripts/verify.sh # or: go test -race ./...
# Build a header block in code and watch it decode (a tiny program, or
# adapt TestHpackEncodeDecodeRoundTrip). Then inspect a real stream:
go build -o /tmp/h2inspect ./cmd/h2inspect
# Generate a cleartext h2c capture with curl/nghttp and pipe it in:
# nghttp -v --no-tls http://127.0.0.1:9000/ -m 4 # multiplex 4 reqs
# (real headers are Huffman-coded; h2inspect prints frames + marks
# [huffman] for header blocks — the framing/demux still works.)
8. Exercises
- Add Huffman decode for the static-table-only case using
golang.org/x/net/http2/hpackbehind a build tag, and feedh2inspecta realcurl --http2-prior-knowledgecapture. Confirm your frame trace matchesnghttp -v. - Reproduce the gRPC LB skew (steps/03): one channel, 1000 calls, count per-backend hits through an L4 LB vs an L7 path. Quantify it.
- Make a too-small window throttle throughput: lower the initial
window in
FlowController, simulate a high-RTT link by delayingWINDOW_UPDATEs, and show throughput ≈ window/RTT. - Detect a trailers-only failure: extend
Demuxto capture the second (trailer) HEADERS frame and surfacegrpc-status; prove a 200-but-failed call is detectable. This is what gw-11 needs. - Implement
GOAWAYdraining: track the last-processed stream id, reject new streams after aGOAWAY, and let in-flight finish — the h2 analog of gw-01's connection drain.