Skip to main content

core.net.http2 — HTTP/2 + HPACK

Pure-Verum implementation of HTTP/2 (RFC 7540) and its sibling header compression format HPACK (RFC 7541). core.net.http2 supplies the wire-level primitives — frame encode / decode, HPACK encoder / decoder, stream state machine — from which a client, server, or transparent proxy is assembled.

Spec alignment

SpecTitleCoverage
RFC 7540HTTP/2Frame layer, SETTINGS, stream FSM, GOAWAY, error codes
RFC 7541HPACKStatic table (§A), dynamic table, Huffman (§B), integer codec (§5.1), literal forms (§6.1–§6.3)
RFC 9113HTTP/2 (obsoletes 7540)All clarifications carried forward; stream priority tree is deprecated and not implemented

Module layout

SubmodulePurpose
core.net.http2 (mod)PREFACE (24-byte magic), flat re-exports of the public surface below
core.net.http2.errorHttp2Error scope-tagged variant, ErrorCode with all 14 RFC §7 constants
core.net.http2.frameFrameHeader, Frame typed ADT, FrameType, FrameFlags, encode / decode_payload
core.net.http2.settingsSettings, SettingId, RFC §6.5.2 defaults, bounds-checking apply
core.net.http2.huffmanRFC 7541 §B canonical Huffman table (@intrinsic-backed)
core.net.http2.static_table61-entry HPACK static table (RFC 7541 §A)
core.net.http2.hpackHpackEncoder, HpackDecoder, HeaderField, DynamicTable, HpackError
core.net.http2.streamStreamFsm, StreamState, StreamEvent, StreamTransitionError

Connection preface

Every HTTP/2 connection opens with the client transmitting exactly these 24 bytes (RFC 7540 §3.5) immediately after ALPN-negotiated TLS completes:

mount core.net.http2.{PREFACE};

// PREFACE: [Byte; 24] — "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
stream.write_all(&PREFACE).await?;

let mut got: [Byte; 24] = [0; 24];
read_exact(&mut stream, &mut got).await?;
if got != PREFACE {
return Err(Http2Error.BadClientPreface);
}

After the preface each peer sends a SETTINGS frame; SETTINGS are not optional and appear before any other frame type.

Frame pipeline

A minimal loop:

mount core.net.http2.{FrameHeader, Frame, Settings, SettingId,
Http2Error, ErrorCode, connection_error};

async fn run_frame_loop(
mut stream: TcpStream,
mut settings: Settings,
) -> Result<(), Http2Error> {
loop {
let mut hdr_buf: [Byte; 9] = [0; 9];
read_exact(&mut stream, &mut hdr_buf).await?;
let header = FrameHeader.decode(&hdr_buf)?;

if (header.length as UInt32) > settings.max_frame_size {
return Err(connection_error(
ErrorCode.FRAME_SIZE_ERROR, &"over-limit".into(),
));
}
let payload = read_exact_owned(&mut stream, header.length).await?;
let frame = Frame.decode_payload(&header, &payload)?;

match frame {
Frame.SettingsFrame { ack, params } => {
if !ack {
for (id, value) in params.iter() {
settings.apply(*id, *value)?;
}
send_settings_ack(&mut stream).await?;
}
}
Frame.HeadersFrame { stream_id, block_fragment, end_stream, .. } => {
// HPACK decode + stream FSM step + app dispatch.
}
Frame.DataFrame { stream_id, data, end_stream, .. } => {
// Flow-control accounting + body assembly.
}
Frame.GoAwayFrame { .. } => { return Ok(()); }
_ => { /* PING, WINDOW_UPDATE, RST_STREAM handled by connection driver */ }
}
}
}

Frame types (RFC 7540 §6)

TypeHexPurposeValid stream id
DataFrame0x0Application data; flow-controlledstream ≠ 0
HeadersFrame0x1Request / response header blockstream ≠ 0
PriorityFrame0x2Advisory priority (deprecated in 9113)stream ≠ 0
RstStreamFrame0x3Terminate a single streamstream ≠ 0
SettingsFrame0x4Connection parameters / ACKstream = 0
PushPromiseFrame0x5Server-push reservationstream ≠ 0
PingFrame0x6Keepalive / RTT probestream = 0
GoAwayFrame0x7Graceful connection shutdownstream = 0
WindowUpdateFrame0x8Flow-control credit0 or stream id
ContinuationFrame0x9HEADERS / PUSH_PROMISE tailstream ≠ 0

A frame on the wrong stream id surfaces as a Http2Error.ConnectionError with ErrorCode.PROTOCOL_ERROR — per §5.1.1, stream 0 is reserved for connection-scoped frames and cannot carry request/response data.

SettingId and Settings

public type SettingId is { value: UInt16 };

implement SettingId {
public const HEADER_TABLE_SIZE: UInt16 = 0x1;
public const ENABLE_PUSH: UInt16 = 0x2;
public const MAX_CONCURRENT_STREAMS: UInt16 = 0x3;
public const INITIAL_WINDOW_SIZE: UInt16 = 0x4;
public const MAX_FRAME_SIZE: UInt16 = 0x5;
public const MAX_HEADER_LIST_SIZE: UInt16 = 0x6;
}

Default values and bounds

ParameterRFC defaultBounds
HEADER_TABLE_SIZE4096unsigned 32-bit, no hard upper bound
ENABLE_PUSH1 (true)0 or 1 — any other value is PROTOCOL_ERROR
MAX_CONCURRENT_STREAMSunbounded0 disables new streams (legal)
INITIAL_WINDOW_SIZE65535≤ 2³¹ − 1; overflow is FLOW_CONTROL_ERROR
MAX_FRAME_SIZE163842¹⁴ … 2²⁴ − 1; out of range is PROTOCOL_ERROR
MAX_HEADER_LIST_SIZEunboundedadvisory — peers may still exceed

Settings.apply(id, value) performs these bounds checks and returns a Http2Error.ConnectionError on violation, so a misbehaving peer cannot silently poison the connection state. Unknown SettingIds are ignored (§6.5.2, MUST).

HPACK

HPACK encodes a sequence of header fields as representations:

FormPrefix§Use
Indexed header field0b1xxxxxxx6.1Reference a static/dynamic table entry
Literal w/ incremental indexing0b01xxxxxx6.2.1Emit + insert into dynamic table
Literal w/o indexing0b0000xxxx6.2.2Emit without inserting
Literal never-indexed0b0001xxxx6.2.3Emit; proxies must forward never-indexed
Dynamic table size update0b001xxxxx6.3Shrink / grow the decoder's dynamic table

Encoder / decoder pair

mount core.net.http2.{HpackEncoder, HpackDecoder, HeaderField};

// One encoder per outbound direction; maintains dynamic table state.
let mut enc = HpackEncoder.new();
let mut headers: List<HeaderField> = List.new();
headers.push(HeaderField.new(":method".into(), "GET".into()));
headers.push(HeaderField.new(":scheme".into(), "https".into()));
headers.push(HeaderField.new(":path".into(), "/api/v1".into()));
headers.push(HeaderField.new(":authority".into(), "example.com".into()));
headers.push(HeaderField.new("accept".into(), "application/json".into()));

let mut block: List<Byte> = List.new();
enc.encode(&headers, &mut block);

// Never-indexed literals — §7.1 recommends this for Authorization /
// Cookie to mitigate CRIME-class timing attacks against shared caches.
enc.encode_never_indexed(
&HeaderField.new("authorization".into(), "Bearer t0ken".into()),
&mut block,
);

// One decoder per inbound direction.
let mut dec = HpackDecoder.new();
let parsed: List<HeaderField> = dec.decode(&block)?;

Dynamic table sizing

The dynamic table starts at DEFAULT_HEADER_TABLE_SIZE = 4096 bytes. When the peer's SETTINGS advertise a different HEADER_TABLE_SIZE, the decoder resizes immediately (§6.3). The encoder can schedule a resize via schedule_resize(new_max) — the next encode call emits a §6.3 update representation ahead of the header block.

Per-entry size is name_len + value_len + 32 (§4.1). Entries that would individually exceed max_size are silently dropped rather than evicting the entire table; callers MUST still emit the header literal uncompressed.

Static table (RFC 7541 §A)

61 entries covering common request / response fields (:method GET, :status 200, content-type, cache-control, …). Lookups in core.net.http2.static_table are O(1) by index and return Maybe<(Text, Text)>. The table is frozen; new entries only arrive in the dynamic table.

Stream state machine (RFC 7540 §5.1)

mount core.net.http2.{StreamFsm, StreamEvent, StreamState};

let mut fsm = StreamFsm.new(3); // client streams are odd
fsm.step(&StreamEvent.SendHeaders { end_stream: false })?; // Idle → Open
fsm.step(&StreamEvent.SendData { end_stream: true })?; // Open → HalfClosedLocal
fsm.step(&StreamEvent.RecvHeaders { end_stream: true })?; // HalfClosedLocal → Closed
assert_eq(fsm.state(), StreamState.Closed);

Invalid transitions surface as StreamTransitionError.InvalidTransition { state, event }; the connection driver translates these to either a stream-level RST_STREAM with PROTOCOL_ERROR or a connection-level GOAWAY depending on severity (§5.4.1 vs §5.4.2).

Stream-id allocation

RoleParityHelper
Client-initiatedOddnext_client_stream_id(current) -> Maybe<UInt32>
Server pushEvennext_server_stream_id(current) -> Maybe<UInt32>

Both return None once the 31-bit id space (0x7FFFFFFF) is exhausted — the driver MUST then send GOAWAY and close the connection (RFC 7540 §5.1.1).

Error model

public type Http2Error is
| ConnectionError { code: ErrorCode, reason: Text }
| StreamError { stream_id: UInt32, code: ErrorCode, reason: Text }
| NeedMore
| BadClientPreface
| MalformedFrame(Text)
| HpackError(Text)
| FrameSizeExceeded { size: Int, limit: Int };

ErrorCode constants (RFC 7540 §7)

HexNameMeaning
0x00NO_ERRORClean shutdown
0x01PROTOCOL_ERRORGeneric spec violation
0x02INTERNAL_ERRORPeer internal error
0x03FLOW_CONTROL_ERRORWindow accounting inconsistent
0x04SETTINGS_TIMEOUTSETTINGS ACK took too long
0x05STREAM_CLOSEDReceived frames on a closed stream
0x06FRAME_SIZE_ERRORFrame exceeded MAX_FRAME_SIZE
0x07REFUSED_STREAMServer refused to process stream — safe to retry
0x08CANCELRequest is no longer needed
0x09COMPRESSION_ERRORHPACK state corrupted — connection-fatal
0x0ACONNECT_ERRORCONNECT-method tunnel failed
0x0BENHANCE_YOUR_CALMAdmission control — back off
0x0CINADEQUATE_SECURITYTLS parameters too weak
0x0DHTTP_1_1_REQUIREDPeer cannot speak HTTP/2 on this request

connection_error(code, reason) and stream_error(stream_id, code, reason) are the canonical constructors; they wrap the raw u32 code in ErrorCode.new and clone the reason text.

Flow control

INITIAL_WINDOW_SIZE (default 65535) bounds how much DATA each peer may transmit before receiving a WINDOW_UPDATE. Each DATA frame decrements both the stream-level and the connection-level window; a window that would go negative is a FLOW_CONTROL_ERROR. The frame layer tracks the raw credit integers; higher-level orchestration (BDP-driven auto-tuning, per-stream prioritisation) sits above this module and is typically implemented in the weft middleware layer.

Deferred

  • Full RFC 7540 §5.3 priority tree (deprecated in RFC 9113 in favour of the extensible-priorities scheme used by HTTP/3).
  • Automatic BDP-based window auto-tuning (lives in the connection driver, not the wire layer).
  • ALPN negotiation glue — core.net.tls surfaces the selected ALPN.

See also

  • stdlib/net/http3 — HTTP/3 + QPACK; shares the static-table layout philosophy.
  • stdlib/net/tls — TLS 1.3 handshake that carries the h2 ALPN identifier.
  • stdlib/compress — optional permessage-deflate-style bodies sit above HPACK, not inside it.