Skip to main content

core.net.h3 — HTTP/3 + QPACK

A pure-Verum implementation of HTTP/3, QPACK header compression, and the extensible-priorities scheme:

SpecTitleScope
RFC 9114HTTP/3Frame layer, SETTINGS, streams, requests, responses
RFC 9204QPACK: Field Compression for HTTP/3Static + dynamic tables, encoder and decoder state
RFC 9218Extensible Prioritization Scheme for HTTPpriority header / PRIORITY_UPDATE frame
RFC 7541 App BHuffman tableReused by QPACK string encoding

core.net.h3 sits on top of core.net.quic. The QUIC layer provides the streams; HTTP/3 frames flow inside them. Header compression (QPACK) uses a pair of reserved unidirectional streams per direction, exactly as the RFC prescribes.

Module map​

ConcernModuleKey types
Frame layer (DATA / HEADERS / SETTINGS / CANCEL_PUSH / …)core.net.h3.frameH3Frame, H3FrameError
Settingscore.net.h3.settingsH3Settings, H3SettingsId
Requests / responsescore.net.h3.requestH3Request, H3Response, H3Method
Client facadecore.net.h3.clientH3Client, ClientOptions, H3ClientError
Server facadecore.net.h3.serverH3Server, ServerOptions, H3Handler, H3ServerError
Connection drivercore.net.h3.connectionH3Connection, new_client, new_server, on_bidi_stream
Priority (RFC 9218)core.net.h3.priorityPriority, Urgency, Incremental
QPACK encodercore.net.h3.qpack.encoderEncoder, EncoderInstruction
QPACK decodercore.net.h3.qpack.decoderDecoder, DecoderInstruction, HeaderField
QPACK static table (99 entries)core.net.h3.qpack.static_tableSTATIC_TABLE, lookup
QPACK dynamic tablecore.net.h3.qpack.dynamic_tableDynamicTable, capacity + eviction
Huffman (RFC 7541 App B)core.net.h3.qpack.huffmanencode, decode, canonical table
Errorscore.net.h3.errorH3Error, H3ErrorCode

Client flow​

mount core.net.h3.client.{H3Client, ClientOptions, H3ClientError};
mount core.net.h3.request.{H3Request};
mount core.time.duration.{Duration};

async fn fetch_example() -> Result<(), H3ClientError> {
// `ClientOptions` is a record with two constructors — `default()`
// and `with_system_trust()` — and NO builder methods. The knobs are
// set by record update.
let opts = ClientOptions {
idle_timeout: Duration.from_secs(30),
max_field_section_size: 65536_u64,
..ClientOptions.default()
};

let mut client = H3Client.connect(&"https://example.test/", opts).await?;

// Two ways in. `client.get(path)` / `client.post(path, ct, body)`
// are the short forms; `H3Request.get(authority, path)` plus
// `client.send(&req)` is the long one, and it is what you need as
// soon as a request carries its own headers.
let req = H3Request.get(client.authority(), Text.from("/api/users/42"))
.with_header(Text.from("accept"), Text.from("application/json"));
let response = client.send(&req).await?;

// `status`, `headers`, `body` and `trailers` are FIELDS of
// `H3Response`, not accessor methods.
let status: UInt16 = response.status;
for header in response.headers.iter() {
let _ = (header.name, header.value);
}
let body: List<Byte> = response.body;
let _ = (status, body);
Result.Ok(())
}

H3Client.connect performs QUIC connect + TLS 1.3 handshake + ALPN negotiation + H3 unistream setup (control, qpack encoder, qpack decoder) before returning. By the time client.send(&req) is called, the connection is in 1-RTT and HEADERS frames can be issued immediately. H3Request.get(authority, path) and H3Request.post(authority, path, body) are the two request builders, and .with_header(name, value) chains onto either. The client also carries get / post / send / connect_resumed directly — they are declared pub rather than public, which grammar/verum.ebnf:497 admits as the same visibility.

Server flow​

mount core.net.h3.server.{H3Server, ServerOptions, H3Handler};
mount core.net.addr.{SocketAddr};
mount core.net.h3.request.{H3Request, H3Response, H3Method};

type MyHandler is { /* app state */ };

implement H3Handler for MyHandler {
async fn handle(&mut self, req: H3Request) -> H3Response {
match (req.method(), req.path().as_str()) {
(H3Method.Get, "/health") =>
// `ok` TAKES the body; there is no `with_body`.
H3Response.ok(Text.from("ok").as_bytes())
.with_header("content-type", "text/plain"),

_ => H3Response.status(404_u16),
}
}
}

async fn serve() -> Result<(), H3ServerError> {
let handler = MyHandler { /* ... */ };
// `ServerOptions` has no `default()` and no PEM builders. Its
// constructor takes a parsed chain and a signer; `with_alpn` and
// `with_idle_timeout` are the only builders.
let opts = ServerOptions.from_cert(cert_chain, signer)
.with_alpn(alpn_list);
// `SocketAddr.from_text`, not `"…".parse()`: `Text` has no `parse`
// method, and the call binds an unrelated free function taking
// `&Text` — it type-checks and panics at run time.
let addr = SocketAddr.from_text(&"0.0.0.0:443")?;
let server = H3Server.bind(&addr, opts).await?;
server.serve(handler).await
}
Corrected, and re-counted

H3Response has two constructors, one builder and one converter — four public methods, which is the list below. The sentence here said "three constructors and one builder" and disagreed with its own list:

H3Response.ok(body: List<Byte>) -> H3Response // 200, body included
H3Response.status(code: UInt16) -> H3Response // any code, EMPTY body
resp.with_header(name: Text, value: Text) -> H3Response
resp.to_field_list() -> List<QpackHeaderField>

with_body, streaming and html do not exist. A body goes in through ok; to give a non-200 response a body, update the record directly — status, headers, body and trailers are public fields.

The H3Handler protocol is async fn(H3Request) -> H3Response; the server handles connection setup, stream multiplexing, and QPACK state in the background.

QPACK​

QPACK is HPACK-for-HTTP/3 — header compression with the wrinkle that encoder state updates flow on a unidirectional stream so that the receiver can process header blocks out of order.

  • Static table — 99 entries per RFC 9204 Appendix A. Constant lookups; always safe to reference by index.
  • Dynamic table — bounded by max_field_section_size and qpack_max_table_capacity negotiated via SETTINGS. The decoder maintains "known-received" indices via the QPACK_DECODER stream; the encoder blocks on insertions whose acknowledgement hasn't been observed.
  • Huffman — RFC 7541 Appendix B table, reused verbatim. String literals are encoded Huffman-compressed when that strictly decreases byte count.

One session, both directions​

There is no Encoder type and no Decoder type. QPACK is a single QpackSession carrying both dynamic tables — yours and the peer's — which is why its constructor takes two capacities:

mount core.net.h3.qpack.session.{QpackSession};
mount core.net.h3.qpack.{QpackHeaderField};

fn roundtrip(
headers: &List<QpackHeaderField>,
) -> Result<List<QpackHeaderField>, QpackDecodeError> {
let mut qp = QpackSession.new(0, 0); // own cap, peer cap
let wire = qp.encode_field_section(headers)?;
qp.decode_field_section(wire.as_slice())
}

The instruction streams are the session's too — drain_encoder_out, drain_decoder_out, on_peer_encoder_bytes, on_peer_decoder_bytes — and capacity is changed with set_encoder_capacity, not set_max_capacity.

With dynamic table capacity 0 (the v0.1 default), the encoder runs in "static + literal" mode — no inter-stream coordination needed. This matches what every major HTTP/3 client negotiates today. Dynamic-table mode lands in a v1.1 followup and requires wiring the encoder's blocking/unblocking queue into the QUIC stream scheduler.

Known-answer test coverage​

TestFile
Static table — 99 entries + out-of-range rejectionvcs/specs/L2-standard/net/h3/qpack_static_table_coverage.vr
Huffman round-trip (RFC 7541 §C.4 + 256-byte stress)vcs/specs/L2-standard/net/h3/qpack_huffman_roundtrip.vr
Encoder/decoder round-trip (4 representation variants)vcs/specs/L2-standard/net/h3/qpack_encoder_decoder_roundtrip.vr

Priorities​

core.net.h3.priority parses the priority header (RFC 9218 §4) and the PRIORITY_UPDATE frame (§6). Priorities carry two parameters:

  • u — urgency, integer 0–7 (0 is highest), default 3.
  • i — incremental, boolean, default false. When true, the server can interleave DATA chunks from multiple concurrent requests at the same urgency.
// The type is `H3Priority`, and `urgency` / `incremental` are FIELDS,
// not accessor methods. There is no `Urgency` type — urgency is a
// `UInt8` in 0..7 (0 = highest, default 3) per RFC 9218 §4.1.
mount core.net.h3.priority.{H3Priority, parse_header};

// `parse_header` is a FREE function and takes the raw header VALUE as
// bytes, not a Text — there is no `H3Priority.parse`.
let p = parse_header(&"u=1, i".as_bytes())
.unwrap_or(H3Priority.default());
let _ = (p.urgency, p.incremental); // UInt8, Bool

Priorities are hints — the server-side scheduler uses them to order outbound DATA frames but is not obligated to obey (per §5).

Refinement contracts​

H3 invariants (the bulk of H3 is byte-level shuffling, but the QPACK dynamic table carries one):

InvariantModule
Dynamic table size ≤ negotiated capacityqpack.dynamic_table
HEADERS frame Huffman tree well-formed (RFC 7541 Appendix B)qpack.huffman
Control stream id < 4 (reserved)connection

Deep-dive pages​

Status​

Client + server facades ship. QPACK baseline (max_table_capacity=0) is production-ready; dynamic-table encoder extensions are scaffolded for v1.1. The L2 conformance suite: 24/24 byte-exact + surface tests covering every frame type, full QPACK instruction + field section encoding, priority parsing, and the push emitter lifecycle. The RFC 9204 Huffman codec passes byte-for-byte against the canonical HPACK table (rfc9204_qpack_huffman).

See also​

  • core.net.quic — the transport HTTP/3 sits on.
  • core.net.tls13 — TLS 1.3 handshake consumed by QUIC.
  • core.net.weft — middleware (circuit breakers, retries, rate limiters) sits above H3 on the server side.