HTTP/2 From Scratch: Part 4
This post is part of the HTTP from Scratch series.
Part 3 ended on a high note: we got a real 200 OK from a server. But the code felt brittle. We were crafting requests by hand-picking hex codes from a spec sheet (0x82 for GET, 0x84 for /) and our “client” couldn’t have been less practical. It was a proof-of-concept held together with duct tape and hope.
Today, we’re rebuilding it properly. We’ll finish our HPACK implementation and, most importantly, refactor the raw socket logic into a clean Go client that speaks the language of the standard library: http.Request and http.Response.
Previously, we only implemented the Static Table, a read-only list of 61 common headers. But the real power of HTTP/2 comes from the Dynamic Table.
The Dynamic Table is essentially a short-term memory, or cache, that both the client and server build together for the duration of a single TCP connection.
- Client sends the header
x-user-id: 123using the “Literal Header Field with Incremental Indexing” representation. This tells the server to use the value and add it to its Dynamic Table. - Server acknowledges and adds it to its table at Index 62.
- Client sends a second request. Instead of sending the string
x-user-id: 123again, it just sends the byte for Index 62.
To make this work, we need to handle a few new concepts in our hpack.go.
1. Huffman Decoding
Next up is Huffman coding. HTTP/2 uses it to shrink header strings. The specification includes a canonical Huffman table for this, but it’s massive. Implementing it would mean embedding a 400-line static lookup table and writing a tree-walker to encode/decode it. I briefly considered it, then imagined debugging a single bit-flip error in that tree.
That’s why I’m using golang.org/x/net/http2/hpack for this one part. It’s the perfect example of when building “from scratch” becomes counter-productive. We can pull in a battle-tested implementation for the tedious part and keep our focus on the protocol’s state and frame logic.
// We import the official helper just for the string decoding math
import "golang.org/x/net/http2/hpack"
// ... inside our decodeString method
if huffman {
return hpack.HuffmanDecodeToString(data)
}
Implementing hpack by hand can be a personal exercise for you later!
2. The Decoder Loop
Our previous Decode method was incomplete. We need to handle four specific bit-patterns defined in RFC 7541.
Aside from the standard Indexed fields (where we just look up a value), we now have to handle Literals. Some literals request Incremental Indexing (meaning the receiver saves them to its dynamic table for later), while others are Non-Indexed (one-offs for things like tokens that shouldn’t be cached). Finally, we might see a Table Size Update, which tells us if the server resized its compression window.
Here is the updated loop using bitmasks to identify the type:
func (h *HPACKDecoder) Decode(payload []byte) ([]HeaderField, error) {
var headers []HeaderField
r := bytes.NewReader(payload)
for r.Len() > 0 {
b, _ := r.ReadByte()
// Indexed Header Field (starts with 1xxxxxxx)
if b&maskIndexed == patternIndexed {
// ... fetch from Static or Dynamic table ...
// Literal with Incremental Indexing (starts with 01xxxxxx)
// (Read the header, then ADD it to the Dynamic Table)
} else if b&maskLiteralIncremental == patternLiteralIncremental {
header, _ := h.decodeLiteralHeader(r, 6, true)
headers = append(headers, header)
// Literal without Indexing (starts with 0000xxxx or 0001xxxx)
// (Read the header, do NOT add to Dynamic Table)
} else if b&maskLiteral == patternLiteral {
header, _ := h.decodeLiteralHeader(r, 4, false)
headers = append(headers, header)
// Dynamic Table Size Update (starts with 001xxxxx)
} else if b&maskDynamicTableSize == patternDynamicTableSize {
// ... update max size ...
}
}
return headers, nil
}
full hpack.go (click to expand)View on GitHub
package main
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"golang.org/x/net/http2/hpack"
)
// RFC 7541: HPACK: Header Compression for HTTP/2
// https://datatracker.ietf.org/doc/html/rfc7541
const (
// Masks for HPACK header field types
maskIndexed = 0x80 // 10000000
maskLiteralIncremental = 0xc0 // 11000000
maskDynamicTableSize = 0xe0 // 11100000
maskLiteral = 0xf0 // 11110000
// Patterns for HPACK header field types
patternIndexed = 0x80 // 10000000
patternLiteralIncremental = 0x40 // 01000000
patternDynamicTableSize = 0x20 // 00100000
patternLiteralNever = 0x10 // 00010000
patternLiteral = 0x00 // 00000000
HuffmanFlagMask = 0x80
IntegerContinuationMask = 0x80
)
// HeaderField represents a header field with a name and value.
type HeaderField struct {
Name, Value string
}
// StaticTable is the predefined, unchangeable table of header fields, as defined in RFC 7541 Appendix A.
var StaticTable = []HeaderField{
{Name: ":authority", Value: ""},
{Name: ":method", Value: "GET"},
{Name: ":method", Value: "POST"},
{Name: ":path", Value: "/"},
{Name: ":path", Value: "/index.html"},
{Name: ":scheme", Value: "http"},
{Name: ":scheme", Value: "https"},
{Name: ":status", Value: "200"},
{Name: ":status", Value: "204"},
{Name: ":status", Value: "206"},
{Name: ":status", Value: "304"},
{Name: ":status", Value: "400"},
{Name: ":status", Value: "404"},
{Name: ":status", Value: "500"},
{Name: "accept-charset", Value: ""},
{Name: "accept-encoding", Value: "gzip, deflate"},
{Name: "accept-language", Value: ""},
{Name: "accept-ranges", Value: ""},
{Name: "accept", Value: ""},
{Name: "access-control-allow-origin", Value: ""},
{Name: "age", Value: ""},
{Name: "allow", Value: ""},
{Name: "authorization", Value: ""},
{Name: "cache-control", Value: ""},
{Name: "content-disposition", Value: ""},
{Name: "content-encoding", Value: ""},
{Name: "content-language", Value: ""},
{Name: "content-length", Value: ""},
{Name: "content-location", Value: ""},
{Name: "content-range", Value: ""},
{Name: "content-type", Value: ""},
{Name: "cookie", Value: ""},
{Name: "date", Value: ""},
{Name: "etag", Value: ""},
{Name: "expect", Value: ""},
{Name: "expires", Value: ""},
{Name: "from", Value: ""},
{Name: "host", Value: ""},
{Name: "if-match", Value: ""},
{Name: "if-modified-since", Value: ""},
{Name: "if-none-match", Value: ""},
{Name: "if-range", Value: ""},
{Name: "if-unmodified-since", Value: ""},
{Name: "last-modified", Value: ""},
{Name: "link", Value: ""},
{Name: "location", Value: ""},
{Name: "max-forwards", Value: ""},
{Name: "proxy-authenticate", Value: ""},
{Name: "proxy-authorization", Value: ""},
{Name: "range", Value: ""},
{Name: "referer", Value: ""},
{Name: "retry-after", Value: ""},
{Name: "server", Value: ""},
{Name: "set-cookie", Value: ""},
{Name: "strict-transport-security", Value: ""},
{Name: "transfer-encoding", Value: ""},
{Name: "user-agent", Value: ""},
{Name: "vary", Value: ""},
{Name: "via", Value: ""},
{Name: "www-authenticate", Value: ""},
}
var (
staticTableMap = make(map[HeaderField]int)
staticTableNameMap = make(map[string]int)
)
func init() {
for i, hf := range StaticTable {
staticTableMap[hf] = i + 1
if _, ok := staticTableNameMap[hf.Name]; !ok {
staticTableNameMap[hf.Name] = i + 1
}
}
}
type DynamicTable struct {
headers []HeaderField
size uint32
maxSize uint32
}
func NewDynamicTable(maxSize uint32) *DynamicTable {
return &DynamicTable{
maxSize: maxSize,
}
}
func (d *DynamicTable) At(i int) (HeaderField, bool) {
if i < 0 || i >= len(d.headers) {
return HeaderField{}, false
}
return d.headers[i], true
}
func (d *DynamicTable) Add(h HeaderField) {
size := uint32(len(h.Name) + len(h.Value) + 32)
for d.size+size > d.maxSize && len(d.headers) > 0 {
last := d.headers[len(d.headers)-1]
d.size -= uint32(len(last.Name) + len(last.Value) + 32)
d.headers = d.headers[:len(d.headers)-1]
}
d.headers = append([]HeaderField{h}, d.headers...)
d.size += size
}
func (d *DynamicTable) SetMaxSize(size uint32) {
d.maxSize = size
for d.size > d.maxSize && len(d.headers) > 0 {
last := d.headers[len(d.headers)-1]
d.size -= uint32(len(last.Name) + len(last.Value) + 32)
d.headers = d.headers[:len(d.headers)-1]
}
}
type HPACKDecoder struct {
dynamicTable *DynamicTable
}
func NewHPACKDecoder(maxSize uint32) *HPACKDecoder {
return &HPACKDecoder{
dynamicTable: NewDynamicTable(maxSize),
}
}
func (h *HPACKDecoder) Header(i int) (HeaderField, bool) {
if i <= 0 {
return HeaderField{}, false
}
if i <= len(StaticTable) {
return StaticTable[i-1], true
}
return h.dynamicTable.At(i - len(StaticTable) - 1)
}
func (h *HPACKDecoder) Decode(payload []byte) ([]HeaderField, error) {
var headers []HeaderField
r := bytes.NewReader(payload)
for r.Len() > 0 {
b, _ := r.ReadByte()
if b&maskIndexed == patternIndexed { // Indexed Header Field
index, n := decodeInt(b, r, 7)
if n < 0 {
return nil, fmt.Errorf("failed to decode integer")
}
header, ok := h.Header(index)
if !ok {
return nil, fmt.Errorf("invalid header index: %d", index)
}
headers = append(headers, header)
fmt.Printf(" [Header] %s: %s\n", header.Name, header.Value)
} else if b&maskLiteralIncremental == patternLiteralIncremental { // Literal Header Field with Incremental Indexing
index, n := decodeInt(b, r, 6)
if n < 0 {
return nil, fmt.Errorf("failed to decode integer")
}
header, err := h.decodeLiteralHeader(r, index, true)
if err != nil {
return nil, err
}
headers = append(headers, header)
fmt.Printf(" [Header] %s: %s\n", header.Name, header.Value)
} else if b&maskLiteral == patternLiteral || b&maskLiteral == patternLiteralNever { // Literal Header Field without or never indexed
index, n := decodeInt(b, r, 4)
if n < 0 {
return nil, fmt.Errorf("failed to decode integer")
}
header, err := h.decodeLiteralHeader(r, index, false)
if err != nil {
return nil, err
}
headers = append(headers, header)
fmt.Printf(" [Header] %s: %s\n", header.Name, header.Value)
} else if b&maskDynamicTableSize == patternDynamicTableSize { // Dynamic Table Size Update
size, n := decodeInt(b, r, 5)
if n < 0 {
return nil, fmt.Errorf("failed to decode integer")
}
h.dynamicTable.SetMaxSize(uint32(size))
} else {
return nil, fmt.Errorf("not implemented: unknown header field type %08b", b)
}
}
return headers, nil
}
func (h *HPACKDecoder) decodeLiteralHeader(r *bytes.Reader, index int, addToDynamicTable bool) (HeaderField, error) {
var name string
var err error
if index > 0 {
header, ok := h.Header(index)
if !ok {
return HeaderField{}, fmt.Errorf("invalid header index: %d", index)
}
name = header.Name
} else {
name, err = h.decodeString(r)
if err != nil {
return HeaderField{}, err
}
}
value, err := h.decodeString(r)
if err != nil {
return HeaderField{}, err
}
header := HeaderField{Name: name, Value: value}
if addToDynamicTable {
h.dynamicTable.Add(header)
}
return header, nil
}
func (h *HPACKDecoder) decodeString(r *bytes.Reader) (string, error) {
b, _ := r.ReadByte()
huffman := b&HuffmanFlagMask == HuffmanFlagMask
length, n := decodeInt(b, r, 7)
if n < 0 {
return "", fmt.Errorf("failed to decode integer")
}
if r.Len() < length {
return "", io.ErrUnexpectedEOF
}
data := make([]byte, length)
r.Read(data)
if huffman {
return hpack.HuffmanDecodeToString(data)
}
return string(data), nil
}
func decodeInt(b byte, r *bytes.Reader, n int) (int, int) {
mask := (1 << n) - 1
i := int(b) & mask
if i < mask {
return i, 1
}
var m uint = 0
bytesRead := 1
var val uint64
for {
b, err := r.ReadByte()
if err != nil {
return 0, -bytesRead
}
bytesRead++
val |= uint64(b&127) << m
m += 7
if b&IntegerContinuationMask == 0 {
break
}
}
return i + int(val), bytesRead
}
type HPACKEncoder struct {
dynamicTable *DynamicTable
}
func NewHPACKEncoder(maxSize uint32) *HPACKEncoder {
return &HPACKEncoder{
dynamicTable: NewDynamicTable(maxSize),
}
}
func (e *HPACKEncoder) Encode(headers []HeaderField) []byte {
var buf bytes.Buffer
for _, hf := range headers {
// Find a match in static table
if index, ok := staticTableMap[hf]; ok {
encodeInt(&buf, index, 7, patternIndexed)
continue
}
// Find a name match in static table
if index, ok := staticTableNameMap[hf.Name]; ok {
encodeInt(&buf, index, 6, patternLiteralIncremental)
encodeString(&buf, hf.Value)
e.dynamicTable.Add(hf)
continue
}
// Literal with literal name
encodeInt(&buf, 0, 6, patternLiteralIncremental)
encodeString(&buf, hf.Name)
encodeString(&buf, hf.Value)
e.dynamicTable.Add(hf)
}
return buf.Bytes()
}
func encodeInt(buf *bytes.Buffer, i int, n int, pattern byte) {
mask := (1 << n) - 1
if i < mask {
buf.WriteByte(pattern | byte(i))
} else {
buf.WriteByte(pattern | byte(mask))
i -= mask
varint := make([]byte, binary.MaxVarintLen64)
c := binary.PutUvarint(varint, uint64(i))
buf.Write(varint[:c])
}
}
func encodeString(buf *bytes.Buffer, s string) {
// no huffman for now
encodeInt(buf, len(s), 7, 0x00) // This is patternLiteral. We are encoding a raw string.
buf.WriteString(s)
}
Writing an Encoder
In the last part, we manually crafted our request bytes (0x82, 0x84…). That was great for learning, but terrible for usability. We need an HPACKEncoder.
Our encoder will use a “naive” but compliant strategy to compress headers:
- Perfect Match: Is the full header (
:method: GET) already in the Static Table? If yes, send the 1-byte index. - Name Match: Is just the name (
:authority) in the Static Table? If yes, send the index for the name, but write the value as a string literal and instruct the server to index it. - No Match: Send both the name and value as string literals and instruct the server to index them.
This simple logic covers 90% of use cases without needing complex state tracking on the client side.
func (e *HPACKEncoder) Encode(headers []HeaderField) []byte {
var buf bytes.Buffer
for _, hf := range headers {
// Try to find a full match (Name + Value)
if index, ok := staticTableMap[hf]; ok {
encodeInt(&buf, index, 7, patternIndexed)
continue
}
// Try to find a Name match
if index, ok := staticTableNameMap[hf.Name]; ok {
encodeInt(&buf, index, 6, patternLiteralIncremental) // Literal with Incremental Indexing
encodeString(&buf, hf.Value)
e.dynamicTable.Add(hf) // We must track this too!
continue
}
// Send as a full literal
encodeInt(&buf, 0, 6, patternLiteralIncremental)
encodeString(&buf, hf.Name)
encodeString(&buf, hf.Value)
e.dynamicTable.Add(hf)
}
return buf.Bytes()
}
Building a Real Client
Now for the satisfying part. We are going to take all that raw socket code from main.go and wrap it in a clean struct that mimics the standard library. Our goal here is to integrate seamlessly with Go’s net/http package, allowing us to leverage familiar types.
// client.go
type Client struct {
Timeout time.Duration
}
func NewClient() *Client {
return &Client{
Timeout: 30 * time.Second,
}
}
func (c *Client) Do(req *http.Request) (*http.Response, error) {
// ... Connection and Handshake logic ...
// Convert http.Request to HPACK HeaderFields
authority := req.URL.Host
if authority == "" {
authority = req.Host // Fallback for robustness
}
headers := []HeaderField{
{Name: ":method", Value: req.Method},
{Name: ":scheme", Value: req.URL.Scheme},
{Name: ":authority", Value: authority},
{Name: ":path", Value: req.URL.Path},
}
// ... append req.Header ...
// Encode and send the HEADERS frame
// ...
// Read Loop and a Subtle Bug
// ...
return httpResp, nil
}
This part was mostly straightforward. We convert to and from the HeaderField type. We make sure the we use the host in the request as the :authority value. But there is one thing that wasted more time than I’d like to admit.
After sending our request, we enter a loop to read the server’s response. This is where I hit an annoying Go concurrency bug that wasted my time. My first implementation had a defer conn.Close() right after dialing the connection. I figured “Why not close the connection? This toy client will only issue a single request per connection”. Here’s what I was doing:
// The WRONG way
conn, err := tls.Dial(...)
if err != nil { ... }
defer conn.Close() // Problem!
// ... send request ...
return &http.Response{ Body: io.NopCloser(bytes.NewReader(bodyBytes)) }, nil
The problem? defer executes when the function (Do) returns. But the user of our client needs to read the response Body after Do has returned. The connection was being closed before they had a chance to read it! The old version of the client would read the full response body in-line. Switching to act more like http.Client significantly changed when the response body is read and, in turn, changed the lifecycle of the underlying connection.
The fix is to remove the defer and instead wrap the connection itself in a custom io.ReadCloser that we assign to the http.Response.Body. When the user calls resp.Body.Close(), it’s now our responsibility to close the underlying network connection. This is the standard pattern used by Go’s own net/http client.
Here’s the read loop that replaces the ... above.
// The Read Loop
var respHeaders []HeaderField
var respBody []byte
for {
frame, err := ReadFrame(conn)
if err != nil {
return nil, fmt.Errorf("connection closed: %w", err)
}
switch frame.Header.Type {
case FrameData:
respBody = append(respBody, frame.Payload...)
case FrameHeaders:
headers, err := hpackDec.Decode(frame.Payload)
if err != nil {
return nil, fmt.Errorf("hpack error: %w", err)
}
respHeaders = append(respHeaders, headers...)
case FrameGoAway:
// Handle server telling us to go away
case FrameWindowUpdate:
// Handle flow control
}
// The stream is finished when we receive a frame with the END_STREAM flag.
// This can be on a HEADERS frame or the final DATA frame.
isEndFrame := frame.Header.Type == FrameData || frame.Header.Type == FrameHeaders
if isEndFrame && (frame.Header.Flags&FlagEndStream != 0) {
break
}
}
// ... build http.Response, then assign our custom Body ...
httpResp.Body = &responseBody{bytes.NewReader(respBody), conn}
return httpResp, nil
// And the helper struct that makes this possible:
type responseBody struct {
*bytes.Reader
conn io.Closer
}
func (rb *responseBody) Close() error {
return rb.conn.Close()
}
full client.go (click to expand)View on GitHub
package main
import (
"bytes"
"crypto/tls"
"encoding/binary"
"fmt"
"io"
"net/http"
"time"
)
const (
// Protocol constants
Preface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
Server = "kmcd.dev:443"
// Frame Types (RFC 9113 Section 6)
FrameData uint8 = 0x0
FrameHeaders uint8 = 0x1
FramePriority uint8 = 0x2
FrameRstStream uint8 = 0x3
FrameSettings uint8 = 0x4
FramePushPromise uint8 = 0x5
FramePing uint8 = 0x6
FrameGoAway uint8 = 0x7
FrameWindowUpdate uint8 = 0x8
FrameContinuation uint8 = 0x9
// Common Flags
FlagAck uint8 = 0x01 // For SETTINGS/PING
FlagEndStream uint8 = 0x01 // For DATA/HEADERS
FlagEndHeaders uint8 = 0x04 // For HEADERS/PUSH_PROMISE/CONTINUATION
)
// responseBody implements io.ReadCloser. It reads from the response body
// buffer and closes the underlying connection when Close is called.
type responseBody struct {
*bytes.Reader
conn io.Closer
}
func (rb *responseBody) Close() error {
fmt.Println("Closing response body and underlying connection.")
return rb.conn.Close()
}
type Client struct {
Timeout time.Duration
}
func NewClient() *Client {
return &Client{
Timeout: 30 * time.Second,
}
}
func (c *Client) Do(req *http.Request) (*http.Response, error) {
// Setup TLS with ALPN
config := &tls.Config{
NextProtos: []string{"h2"},
}
hpackDec := NewHPACKDecoder(4096)
hpackEnc := NewHPACKEncoder(4096)
port := "443"
if req.URL.Port() != "" {
port = req.URL.Port()
}
conn, err := tls.Dial("tcp", req.URL.Hostname()+":"+port, config)
if err != nil {
return nil, fmt.Errorf("failed to connect: %w", err)
}
// Do NOT defer conn.Close(). The response body wrapper will be responsible for it.
state := conn.ConnectionState()
if state.NegotiatedProtocol != "h2" {
conn.Close() // Close connection if h2 is not negotiated
return nil, fmt.Errorf("server did not negotiate HTTP/2: %s", state.NegotiatedProtocol)
}
fmt.Printf("Connected to %s using %s\n", req.URL.Host, state.NegotiatedProtocol)
// Send Connection Preface
if _, err = conn.Write([]byte(Preface)); err != nil {
conn.Close()
return nil, fmt.Errorf("failed to send preface: %w", err)
}
fmt.Println("Preface sent.")
// Initial Handshake Loop (using the Client's hpackDec)
// Send our initial empty settings
mySettings := []byte{0, 0, 0, FrameSettings, 0, 0, 0, 0, 0}
conn.Write(mySettings)
serverSettingsAcked := false
mySettingsAcked := false
for !serverSettingsAcked || !mySettingsAcked {
frame, err := ReadFrame(conn) // ReadFrame needs the raw conn
if err != nil {
conn.Close()
return nil, fmt.Errorf("handshake read error: %w", err)
}
fmt.Printf("<<< [Handshake] Frame Type=%d, Flags=%d, Stream=%d\n",
frame.Header.Type, frame.Header.Flags, frame.Header.StreamID)
switch frame.Header.Type {
case FrameSettings:
if frame.Header.Flags&FlagAck != 0 {
mySettingsAcked = true
fmt.Println("<<< Server ACK'd our settings")
} else {
ack := []byte{0, 0, 0, FrameSettings, FlagAck, 0, 0, 0, 0}
conn.Write(ack)
serverSettingsAcked = true
fmt.Println(">>> Sent SETTINGS ACK")
}
case FrameWindowUpdate:
fmt.Println("<<< Server provided flow control window")
case FrameGoAway:
conn.Close()
return nil, fmt.Errorf("server sent GOAWAY during handshake")
}
}
// Now the actual request sending logic from previous Do method
authority := req.URL.Host
if authority == "" {
authority = req.Host // Fallback if URL.Host is empty
}
scheme := "https"
if req.URL.Scheme != "" {
scheme = req.URL.Scheme
}
headers := []HeaderField{
{Name: ":method", Value: req.Method},
{Name: ":scheme", Value: scheme},
{Name: ":authority", Value: authority},
{Name: ":path", Value: req.URL.Path},
}
for name, values := range req.Header {
for _, value := range values {
headers = append(headers, HeaderField{Name: name, Value: value})
}
}
requestPayload := hpackEnc.Encode(headers)
header := make([]byte, 9)
payloadLen := len(requestPayload)
header[0] = byte(payloadLen >> 16)
header[1] = byte(payloadLen >> 8)
header[2] = byte(payloadLen)
header[3] = FrameHeaders
header[4] = FlagEndStream | FlagEndHeaders
binary.BigEndian.PutUint32(header[5:9], 1)
if _, err := conn.Write(append(header, requestPayload...)); err != nil {
conn.Close()
return nil, fmt.Errorf("failed to send request: %w", err)
}
fmt.Println(">>> Sent HEADERS (Stream 1)")
// Read response (using the conn local to Do)
var respHeaders []HeaderField
var respBody []byte
for {
frame, err := ReadFrame(conn) // ReadFrame needs the raw conn
if err != nil {
conn.Close()
return nil, fmt.Errorf("connection closed: %w", err)
}
fmt.Printf("<<< Frame Type=%d, Flags=%d, Stream=%d\n",
frame.Header.Type, frame.Header.Flags, frame.Header.StreamID)
switch frame.Header.Type {
case FrameData:
respBody = append(respBody, frame.Payload...)
fmt.Printf(" [DATA] Length=%d\n", len(frame.Payload))
case FrameHeaders:
fmt.Println(" [HEADERS] Decoding...")
headers, err := hpackDec.Decode(frame.Payload)
if err != nil {
conn.Close()
return nil, fmt.Errorf("hpack error: %w", err)
}
respHeaders = append(respHeaders, headers...)
case FrameGoAway:
lastStream := binary.BigEndian.Uint32(frame.Payload[0:4]) & 0x7FFFFFFF
errCode := binary.BigEndian.Uint32(frame.Payload[4:8])
conn.Close()
return nil, fmt.Errorf("GOAWAY: Last Stream %d, Error Code %d", lastStream, errCode)
case FrameWindowUpdate:
fmt.Println(" [WINDOW_UPDATE]")
}
isEndFrame := frame.Header.Type == FrameData || frame.Header.Type == FrameHeaders
if isEndFrame && (frame.Header.Flags&FlagEndStream != 0) {
fmt.Println("Stream finished.")
break
}
}
// Build http.Response
httpResp := &http.Response{
StatusCode: 200, // Hardcoded for now
Proto: "HTTP/2.0",
ProtoMajor: 2,
ProtoMinor: 0,
Header: make(http.Header),
Body: &responseBody{bytes.NewReader(respBody), conn},
}
for _, h := range respHeaders {
httpResp.Header.Add(h.Name, h.Value)
if h.Name == ":status" {
fmt.Sscanf(h.Value, "%d", &httpResp.StatusCode)
}
}
httpResp.Status = fmt.Sprintf("%d %s", httpResp.StatusCode, http.StatusText(httpResp.StatusCode))
return httpResp, nil
}
The Result
With this refactor, our main.go transforms from a mess of magical hex values into clean, idiomatic Go:
func main() {
client := NewClient()
req, _ := http.NewRequest("GET", "https://kmcd.dev/", nil)
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
// This now correctly closes our underlying TCP connection.
defer resp.Body.Close()
fmt.Printf("Status: %s\n", resp.Status)
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Body Length: %d bytes\n", len(body))
}
Limitations
While we have a working client, it is strictly a “happy path” implementation:
- Concurrency: Our client is synchronous. It sends a request and waits. Real HTTP/2 uses multiplexing to send many requests at once over a single connection.
- Flow Control: We are ignoring
WINDOW_UPDATEframes. If we tried to download a large file, our connection would stall once the window fills up. - Connection Re-use: We create a new TCP connection for every
Docall. Because the HPACK Dynamic Table is tied to the connection, we aren’t actually gaining any compression benefits across multiple requests. - Many, Many Other Features: There is a lot of small details that this toy client completely glosses over. For example, trailer support. This is funny, because I actually added trailer support for quic-go: #4581, #4630. You can read up more about this when writing my gRPC over HTTP/3 series.
Getting this far, I have a renewed and massive respect for the net/http maintainers. We started this series with hex dumps and now have a client struct that respects io.Closer. The “happy path” alone is a journey through specifications, bit-masking, and subtle concurrency bugs. Handling real-world network conditions, multiplexing, and flow control is a monumental task that makes you appreciate the standard library on a new level.
For now, I’m happy with this victory. We’ve built a real, working HTTP/2 client.
Next Steps
Now that we’ve implemented a decent amount of HTTP/2, I think the next step is to delve into QUIC and HTTP/3. I mentioned earlier that HTTP/2 solves the head-of-line blocking issue. That’s only partially true. Yes, at the application layer HTTP/2 doesn’t have a head-of-line blocking issue but since HTTP/2 is built on top of TCP, it inherits sequential packet ordering. This is normally an amazing feature of TCP but in this case it actually hinders us. Since HTTP/2 multiplexes many independent streams over a single TCP connection, packet loss affecting stream A will still block delivery for stream B. The only way around this is to make significant updates to TCP (not going to happen) or completely abandon TCP altogether.
This, along with 0-RTT connection resumption (sending data before the handshake completes), are things that just aren’t possible to side-step using HTTP/2. This is why QUIC and HTTP/3 were created. But you’ll have to wait a bit longer before seeing me implement that from scratch.
See all of the code mentioned in this article here:go/client.go (click to expand)View on GitHub
package main
import (
"bytes"
"crypto/tls"
"encoding/binary"
"fmt"
"io"
"net/http"
"time"
)
const (
// Protocol constants
Preface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
Server = "kmcd.dev:443"
// Frame Types (RFC 9113 Section 6)
FrameData uint8 = 0x0
FrameHeaders uint8 = 0x1
FramePriority uint8 = 0x2
FrameRstStream uint8 = 0x3
FrameSettings uint8 = 0x4
FramePushPromise uint8 = 0x5
FramePing uint8 = 0x6
FrameGoAway uint8 = 0x7
FrameWindowUpdate uint8 = 0x8
FrameContinuation uint8 = 0x9
// Common Flags
FlagAck uint8 = 0x01 // For SETTINGS/PING
FlagEndStream uint8 = 0x01 // For DATA/HEADERS
FlagEndHeaders uint8 = 0x04 // For HEADERS/PUSH_PROMISE/CONTINUATION
)
// responseBody implements io.ReadCloser. It reads from the response body
// buffer and closes the underlying connection when Close is called.
type responseBody struct {
*bytes.Reader
conn io.Closer
}
func (rb *responseBody) Close() error {
fmt.Println("Closing response body and underlying connection.")
return rb.conn.Close()
}
type Client struct {
Timeout time.Duration
}
func NewClient() *Client {
return &Client{
Timeout: 30 * time.Second,
}
}
func (c *Client) Do(req *http.Request) (*http.Response, error) {
// Setup TLS with ALPN
config := &tls.Config{
NextProtos: []string{"h2"},
}
hpackDec := NewHPACKDecoder(4096)
hpackEnc := NewHPACKEncoder(4096)
port := "443"
if req.URL.Port() != "" {
port = req.URL.Port()
}
conn, err := tls.Dial("tcp", req.URL.Hostname()+":"+port, config)
if err != nil {
return nil, fmt.Errorf("failed to connect: %w", err)
}
// Do NOT defer conn.Close(). The response body wrapper will be responsible for it.
state := conn.ConnectionState()
if state.NegotiatedProtocol != "h2" {
conn.Close() // Close connection if h2 is not negotiated
return nil, fmt.Errorf("server did not negotiate HTTP/2: %s", state.NegotiatedProtocol)
}
fmt.Printf("Connected to %s using %s\n", req.URL.Host, state.NegotiatedProtocol)
// Send Connection Preface
if _, err = conn.Write([]byte(Preface)); err != nil {
conn.Close()
return nil, fmt.Errorf("failed to send preface: %w", err)
}
fmt.Println("Preface sent.")
// Initial Handshake Loop (using the Client's hpackDec)
// Send our initial empty settings
mySettings := []byte{0, 0, 0, FrameSettings, 0, 0, 0, 0, 0}
conn.Write(mySettings)
serverSettingsAcked := false
mySettingsAcked := false
for !serverSettingsAcked || !mySettingsAcked {
frame, err := ReadFrame(conn) // ReadFrame needs the raw conn
if err != nil {
conn.Close()
return nil, fmt.Errorf("handshake read error: %w", err)
}
fmt.Printf("<<< [Handshake] Frame Type=%d, Flags=%d, Stream=%d\n",
frame.Header.Type, frame.Header.Flags, frame.Header.StreamID)
switch frame.Header.Type {
case FrameSettings:
if frame.Header.Flags&FlagAck != 0 {
mySettingsAcked = true
fmt.Println("<<< Server ACK'd our settings")
} else {
ack := []byte{0, 0, 0, FrameSettings, FlagAck, 0, 0, 0, 0}
conn.Write(ack)
serverSettingsAcked = true
fmt.Println(">>> Sent SETTINGS ACK")
}
case FrameWindowUpdate:
fmt.Println("<<< Server provided flow control window")
case FrameGoAway:
conn.Close()
return nil, fmt.Errorf("server sent GOAWAY during handshake")
}
}
// Now the actual request sending logic from previous Do method
authority := req.URL.Host
if authority == "" {
authority = req.Host // Fallback if URL.Host is empty
}
scheme := "https"
if req.URL.Scheme != "" {
scheme = req.URL.Scheme
}
headers := []HeaderField{
{Name: ":method", Value: req.Method},
{Name: ":scheme", Value: scheme},
{Name: ":authority", Value: authority},
{Name: ":path", Value: req.URL.Path},
}
for name, values := range req.Header {
for _, value := range values {
headers = append(headers, HeaderField{Name: name, Value: value})
}
}
requestPayload := hpackEnc.Encode(headers)
header := make([]byte, 9)
payloadLen := len(requestPayload)
header[0] = byte(payloadLen >> 16)
header[1] = byte(payloadLen >> 8)
header[2] = byte(payloadLen)
header[3] = FrameHeaders
header[4] = FlagEndStream | FlagEndHeaders
binary.BigEndian.PutUint32(header[5:9], 1)
if _, err := conn.Write(append(header, requestPayload...)); err != nil {
conn.Close()
return nil, fmt.Errorf("failed to send request: %w", err)
}
fmt.Println(">>> Sent HEADERS (Stream 1)")
// Read response (using the conn local to Do)
var respHeaders []HeaderField
var respBody []byte
for {
frame, err := ReadFrame(conn) // ReadFrame needs the raw conn
if err != nil {
conn.Close()
return nil, fmt.Errorf("connection closed: %w", err)
}
fmt.Printf("<<< Frame Type=%d, Flags=%d, Stream=%d\n",
frame.Header.Type, frame.Header.Flags, frame.Header.StreamID)
switch frame.Header.Type {
case FrameData:
respBody = append(respBody, frame.Payload...)
fmt.Printf(" [DATA] Length=%d\n", len(frame.Payload))
case FrameHeaders:
fmt.Println(" [HEADERS] Decoding...")
headers, err := hpackDec.Decode(frame.Payload)
if err != nil {
conn.Close()
return nil, fmt.Errorf("hpack error: %w", err)
}
respHeaders = append(respHeaders, headers...)
case FrameGoAway:
lastStream := binary.BigEndian.Uint32(frame.Payload[0:4]) & 0x7FFFFFFF
errCode := binary.BigEndian.Uint32(frame.Payload[4:8])
conn.Close()
return nil, fmt.Errorf("GOAWAY: Last Stream %d, Error Code %d", lastStream, errCode)
case FrameWindowUpdate:
fmt.Println(" [WINDOW_UPDATE]")
}
isEndFrame := frame.Header.Type == FrameData || frame.Header.Type == FrameHeaders
if isEndFrame && (frame.Header.Flags&FlagEndStream != 0) {
fmt.Println("Stream finished.")
break
}
}
// Build http.Response
httpResp := &http.Response{
StatusCode: 200, // Hardcoded for now
Proto: "HTTP/2.0",
ProtoMajor: 2,
ProtoMinor: 0,
Header: make(http.Header),
Body: &responseBody{bytes.NewReader(respBody), conn},
}
for _, h := range respHeaders {
httpResp.Header.Add(h.Name, h.Value)
if h.Name == ":status" {
fmt.Sscanf(h.Value, "%d", &httpResp.StatusCode)
}
}
httpResp.Status = fmt.Sprintf("%d %s", httpResp.StatusCode, http.StatusText(httpResp.StatusCode))
return httpResp, nil
}
go/hpack.go (click to expand)View on GitHub
package main
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"golang.org/x/net/http2/hpack"
)
// RFC 7541: HPACK: Header Compression for HTTP/2
// https://datatracker.ietf.org/doc/html/rfc7541
const (
// Masks for HPACK header field types
maskIndexed = 0x80 // 10000000
maskLiteralIncremental = 0xc0 // 11000000
maskDynamicTableSize = 0xe0 // 11100000
maskLiteral = 0xf0 // 11110000
// Patterns for HPACK header field types
patternIndexed = 0x80 // 10000000
patternLiteralIncremental = 0x40 // 01000000
patternDynamicTableSize = 0x20 // 00100000
patternLiteralNever = 0x10 // 00010000
patternLiteral = 0x00 // 00000000
HuffmanFlagMask = 0x80
IntegerContinuationMask = 0x80
)
// HeaderField represents a header field with a name and value.
type HeaderField struct {
Name, Value string
}
// StaticTable is the predefined, unchangeable table of header fields, as defined in RFC 7541 Appendix A.
var StaticTable = []HeaderField{
{Name: ":authority", Value: ""},
{Name: ":method", Value: "GET"},
{Name: ":method", Value: "POST"},
{Name: ":path", Value: "/"},
{Name: ":path", Value: "/index.html"},
{Name: ":scheme", Value: "http"},
{Name: ":scheme", Value: "https"},
{Name: ":status", Value: "200"},
{Name: ":status", Value: "204"},
{Name: ":status", Value: "206"},
{Name: ":status", Value: "304"},
{Name: ":status", Value: "400"},
{Name: ":status", Value: "404"},
{Name: ":status", Value: "500"},
{Name: "accept-charset", Value: ""},
{Name: "accept-encoding", Value: "gzip, deflate"},
{Name: "accept-language", Value: ""},
{Name: "accept-ranges", Value: ""},
{Name: "accept", Value: ""},
{Name: "access-control-allow-origin", Value: ""},
{Name: "age", Value: ""},
{Name: "allow", Value: ""},
{Name: "authorization", Value: ""},
{Name: "cache-control", Value: ""},
{Name: "content-disposition", Value: ""},
{Name: "content-encoding", Value: ""},
{Name: "content-language", Value: ""},
{Name: "content-length", Value: ""},
{Name: "content-location", Value: ""},
{Name: "content-range", Value: ""},
{Name: "content-type", Value: ""},
{Name: "cookie", Value: ""},
{Name: "date", Value: ""},
{Name: "etag", Value: ""},
{Name: "expect", Value: ""},
{Name: "expires", Value: ""},
{Name: "from", Value: ""},
{Name: "host", Value: ""},
{Name: "if-match", Value: ""},
{Name: "if-modified-since", Value: ""},
{Name: "if-none-match", Value: ""},
{Name: "if-range", Value: ""},
{Name: "if-unmodified-since", Value: ""},
{Name: "last-modified", Value: ""},
{Name: "link", Value: ""},
{Name: "location", Value: ""},
{Name: "max-forwards", Value: ""},
{Name: "proxy-authenticate", Value: ""},
{Name: "proxy-authorization", Value: ""},
{Name: "range", Value: ""},
{Name: "referer", Value: ""},
{Name: "retry-after", Value: ""},
{Name: "server", Value: ""},
{Name: "set-cookie", Value: ""},
{Name: "strict-transport-security", Value: ""},
{Name: "transfer-encoding", Value: ""},
{Name: "user-agent", Value: ""},
{Name: "vary", Value: ""},
{Name: "via", Value: ""},
{Name: "www-authenticate", Value: ""},
}
var (
staticTableMap = make(map[HeaderField]int)
staticTableNameMap = make(map[string]int)
)
func init() {
for i, hf := range StaticTable {
staticTableMap[hf] = i + 1
if _, ok := staticTableNameMap[hf.Name]; !ok {
staticTableNameMap[hf.Name] = i + 1
}
}
}
type DynamicTable struct {
headers []HeaderField
size uint32
maxSize uint32
}
func NewDynamicTable(maxSize uint32) *DynamicTable {
return &DynamicTable{
maxSize: maxSize,
}
}
func (d *DynamicTable) At(i int) (HeaderField, bool) {
if i < 0 || i >= len(d.headers) {
return HeaderField{}, false
}
return d.headers[i], true
}
func (d *DynamicTable) Add(h HeaderField) {
size := uint32(len(h.Name) + len(h.Value) + 32)
for d.size+size > d.maxSize && len(d.headers) > 0 {
last := d.headers[len(d.headers)-1]
d.size -= uint32(len(last.Name) + len(last.Value) + 32)
d.headers = d.headers[:len(d.headers)-1]
}
d.headers = append([]HeaderField{h}, d.headers...)
d.size += size
}
func (d *DynamicTable) SetMaxSize(size uint32) {
d.maxSize = size
for d.size > d.maxSize && len(d.headers) > 0 {
last := d.headers[len(d.headers)-1]
d.size -= uint32(len(last.Name) + len(last.Value) + 32)
d.headers = d.headers[:len(d.headers)-1]
}
}
type HPACKDecoder struct {
dynamicTable *DynamicTable
}
func NewHPACKDecoder(maxSize uint32) *HPACKDecoder {
return &HPACKDecoder{
dynamicTable: NewDynamicTable(maxSize),
}
}
func (h *HPACKDecoder) Header(i int) (HeaderField, bool) {
if i <= 0 {
return HeaderField{}, false
}
if i <= len(StaticTable) {
return StaticTable[i-1], true
}
return h.dynamicTable.At(i - len(StaticTable) - 1)
}
func (h *HPACKDecoder) Decode(payload []byte) ([]HeaderField, error) {
var headers []HeaderField
r := bytes.NewReader(payload)
for r.Len() > 0 {
b, _ := r.ReadByte()
if b&maskIndexed == patternIndexed { // Indexed Header Field
index, n := decodeInt(b, r, 7)
if n < 0 {
return nil, fmt.Errorf("failed to decode integer")
}
header, ok := h.Header(index)
if !ok {
return nil, fmt.Errorf("invalid header index: %d", index)
}
headers = append(headers, header)
fmt.Printf(" [Header] %s: %s\n", header.Name, header.Value)
} else if b&maskLiteralIncremental == patternLiteralIncremental { // Literal Header Field with Incremental Indexing
index, n := decodeInt(b, r, 6)
if n < 0 {
return nil, fmt.Errorf("failed to decode integer")
}
header, err := h.decodeLiteralHeader(r, index, true)
if err != nil {
return nil, err
}
headers = append(headers, header)
fmt.Printf(" [Header] %s: %s\n", header.Name, header.Value)
} else if b&maskLiteral == patternLiteral || b&maskLiteral == patternLiteralNever { // Literal Header Field without or never indexed
index, n := decodeInt(b, r, 4)
if n < 0 {
return nil, fmt.Errorf("failed to decode integer")
}
header, err := h.decodeLiteralHeader(r, index, false)
if err != nil {
return nil, err
}
headers = append(headers, header)
fmt.Printf(" [Header] %s: %s\n", header.Name, header.Value)
} else if b&maskDynamicTableSize == patternDynamicTableSize { // Dynamic Table Size Update
size, n := decodeInt(b, r, 5)
if n < 0 {
return nil, fmt.Errorf("failed to decode integer")
}
h.dynamicTable.SetMaxSize(uint32(size))
} else {
return nil, fmt.Errorf("not implemented: unknown header field type %08b", b)
}
}
return headers, nil
}
func (h *HPACKDecoder) decodeLiteralHeader(r *bytes.Reader, index int, addToDynamicTable bool) (HeaderField, error) {
var name string
var err error
if index > 0 {
header, ok := h.Header(index)
if !ok {
return HeaderField{}, fmt.Errorf("invalid header index: %d", index)
}
name = header.Name
} else {
name, err = h.decodeString(r)
if err != nil {
return HeaderField{}, err
}
}
value, err := h.decodeString(r)
if err != nil {
return HeaderField{}, err
}
header := HeaderField{Name: name, Value: value}
if addToDynamicTable {
h.dynamicTable.Add(header)
}
return header, nil
}
func (h *HPACKDecoder) decodeString(r *bytes.Reader) (string, error) {
b, _ := r.ReadByte()
huffman := b&HuffmanFlagMask == HuffmanFlagMask
length, n := decodeInt(b, r, 7)
if n < 0 {
return "", fmt.Errorf("failed to decode integer")
}
if r.Len() < length {
return "", io.ErrUnexpectedEOF
}
data := make([]byte, length)
r.Read(data)
if huffman {
return hpack.HuffmanDecodeToString(data)
}
return string(data), nil
}
func decodeInt(b byte, r *bytes.Reader, n int) (int, int) {
mask := (1 << n) - 1
i := int(b) & mask
if i < mask {
return i, 1
}
var m uint = 0
bytesRead := 1
var val uint64
for {
b, err := r.ReadByte()
if err != nil {
return 0, -bytesRead
}
bytesRead++
val |= uint64(b&127) << m
m += 7
if b&IntegerContinuationMask == 0 {
break
}
}
return i + int(val), bytesRead
}
type HPACKEncoder struct {
dynamicTable *DynamicTable
}
func NewHPACKEncoder(maxSize uint32) *HPACKEncoder {
return &HPACKEncoder{
dynamicTable: NewDynamicTable(maxSize),
}
}
func (e *HPACKEncoder) Encode(headers []HeaderField) []byte {
var buf bytes.Buffer
for _, hf := range headers {
// Find a match in static table
if index, ok := staticTableMap[hf]; ok {
encodeInt(&buf, index, 7, patternIndexed)
continue
}
// Find a name match in static table
if index, ok := staticTableNameMap[hf.Name]; ok {
encodeInt(&buf, index, 6, patternLiteralIncremental)
encodeString(&buf, hf.Value)
e.dynamicTable.Add(hf)
continue
}
// Literal with literal name
encodeInt(&buf, 0, 6, patternLiteralIncremental)
encodeString(&buf, hf.Name)
encodeString(&buf, hf.Value)
e.dynamicTable.Add(hf)
}
return buf.Bytes()
}
func encodeInt(buf *bytes.Buffer, i int, n int, pattern byte) {
mask := (1 << n) - 1
if i < mask {
buf.WriteByte(pattern | byte(i))
} else {
buf.WriteByte(pattern | byte(mask))
i -= mask
varint := make([]byte, binary.MaxVarintLen64)
c := binary.PutUvarint(varint, uint64(i))
buf.Write(varint[:c])
}
}
func encodeString(buf *bytes.Buffer, s string) {
// no huffman for now
encodeInt(buf, len(s), 7, 0x00) // This is patternLiteral. We are encoding a raw string.
buf.WriteString(s)
}
go/main.go (click to expand)View on GitHub
package main
import (
"fmt"
"io"
"log"
"net/http"
)
func main() {
client := NewClient()
req, err := http.NewRequest("GET", "https://kmcd.dev/", nil)
if err != nil {
log.Fatalf("Failed to create request: %v", err)
}
resp, err := client.Do(req)
if err != nil {
log.Fatalf("Failed to execute request: %v", err)
}
defer resp.Body.Close()
fmt.Printf("Protocol: %s\n", resp.Proto)
fmt.Println("Response Status:", resp.Status)
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatalf("Failed to read body: %v", err)
}
fmt.Printf("Response Body: %s\n", string(body))
}
go/parser.go (click to expand)View on GitHub
package main
import (
"encoding/binary"
"fmt"
"io"
)
// FrameHeader represents the 9-byte fixed header of every HTTP/2 frame.
type FrameHeader struct {
Length uint32
Type uint8
Flags uint8
StreamID uint32
}
// Frame represents a complete HTTP/2 frame including its payload.
type Frame struct {
Header FrameHeader
Payload []byte
}
// ReadFrame reads a header and then the corresponding payload from the connection.
func ReadFrame(r io.Reader) (Frame, error) {
// Read the 9-byte header
headerBuf := make([]byte, 9)
_, err := io.ReadFull(r, headerBuf)
if err != nil {
return Frame{}, fmt.Errorf("reading header: %w", err)
}
// Parse the header fields using bit-shifting
header := FrameHeader{
Length: uint32(headerBuf[0])<<16 | uint32(headerBuf[1])<<8 | uint32(headerBuf[2]),
Type: headerBuf[3],
Flags: headerBuf[4],
StreamID: binary.BigEndian.Uint32(headerBuf[5:9]) & 0x7FFFFFFF,
}
// Read the payload based on the Length field
payload := make([]byte, header.Length)
if header.Length > 0 {
_, err = io.ReadFull(r, payload)
if err != nil {
return Frame{}, fmt.Errorf("reading payload: %w", err)
}
}
return Frame{Header: header, Payload: payload}, nil
}
