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.

  1. Client sends the header x-user-id: 123 using the “Literal Header Field with Incremental Indexing” representation. This tells the server to use the value and add it to its Dynamic Table.
  2. Server acknowledges and adds it to its table at Index 62.
  3. Client sends a second request. Instead of sending the string x-user-id: 123 again, 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:

  1. Perfect Match: Is the full header (:method: GET) already in the Static Table? If yes, send the 1-byte index.
  2. 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.
  3. 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_UPDATE frames. 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 Do call. 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
}