Skip to main content

HTTP/3 client

caution
using [Nursery] does not compile

Every listing on this page opens with using [Nursery], and that line does not work today. Measured:

error<E605>: undefined context: Nursery

Nursery is declared as a type — public type Nursery is { … } in core/async/nursery.vr — and using [...] takes a context, which the grammar spells public context Name { … }. core/context/ declares five of those (Random, Logger, Database, Auth, Config); Nursery is not among them.

Mounting the type is not the fix. It quiets the checker and leaves the program wrong: with mount core.async.nursery.{Nursery}; added, the listing type-checks, and running it warns

WARN Context 'Nursery' in function 'main' has no matching context
declaration

before hanging. The loud E605 is the honest answer, so the listings are left as they are until structured concurrency has a real context to name.

Below the using line the QUIC surface has its own gap: QuicConnection is backed entirely by @intrinsic("verum.quic.…"), and all seventeen of those keys sit in the frozen unimplemented set, so a dial would stop there with a panic naming the key — the form measured on sibling families, for example pq. Read rather than run here: the using line stops the listing before the dial is reached.

This note stops being true when Nursery becomes a context, or when the listings stop claiming one.

core.net.h3.client.H3Client wraps QUIC + TLS 1.3 + QPACK into an ergonomic request/response surface. Its connect drives the same pipeline as core.net.quic.api.QuicClient, then wires the H3 connection (control stream + QPACK encoder/decoder streams + SETTINGS negotiation) on top.

Minimum GET​

using [Nursery]

mount core.*;
mount core.net.h3.client.{H3Client, ClientOptions, H3ClientError};

pub async fn main() -> Result<(), H3ClientError> {
let opts = ClientOptions.with_system_trust();
let mut client = H3Client.connect(&f"https://example.com", opts).await?;

let resp = client.get(&f"/").await?;
// `status`, `headers`, `body` and `trailers` are FIELDS of
// `H3Response` (core/net/h3/request.vr:82), not accessor methods.
print(f"status={resp.status} body_len={resp.body.len()}");
Result.Ok(())
}

connect fails early with:

  • UrlParse — malformed URL.
  • UnsupportedScheme — anything other than https://.
  • Resolve — DNS lookup failed.
  • Trust — no usable trust store (empty + verify_hostname = true).
  • Handshake — QUIC / TLS 1.3 handshake aborted.
  • Timeout — exceeded opts.connect_timeout (default 10 s).

Tuning options​

let mut opts = ClientOptions.default();

// Offer both h3 and h3-29 for intermediate peers.
opts.alpn = [b"h3".to_list(), b"h3-29".to_list()];

// Cap inbound response headers at 32 KiB.
opts.max_field_section_size = 32_u64 * 1024_u64;

// Longer timeouts for latency-heavy regions.
opts.connect_timeout = Duration.from_secs(20);
opts.idle_timeout = Duration.from_secs(120);

Defaults (from ClientOptions.default):

FieldDefault
alpn[b"h3"]
max_field_section_sizeu64.MAX (no cap)
idle_timeout30 s
connect_timeout10 s
verify_hostnametrue
trustempty (call with_system_trust() to populate)

POST with body​

// `post(path, content_type, body)` — the content type is a plain
// `&Text` and the body a `List<Byte>`. There is no
// `core.text.content_type` module and no `APPLICATION_JSON` constant
// anywhere in `core/`; write the media type out.
let resp = client.post(&f"/api/v1/upload",
&f"application/json",
body_bytes).await?;
if resp.status != 200_u16 {
return Result.Err(H3ClientError.H3Layer(H3Error.StreamError));
}

Custom request builder​

For full control over headers, use H3Request.new + client.send:

mount core.net.h3.request.{H3Request};

// The builders are `H3Request.get(authority, path)` and
// `H3Request.post(authority, path, body)` — there is no
// `H3Request.new`, no `.header(...)` (it is `.with_header`), and no
// `.body(...)`: the body is an argument to `post`.
let req = H3Request.post(client.authority(), Text.from("/api/v2"), body_bytes)
.with_header(Text.from("content-type"), Text.from("application/json"))
.with_header(Text.from("authorization"), Text.from("Bearer ..."));

let resp = client.send(&req).await?;

Headers are emitted as a QPACK field section — see QPACK. Common static-table hits (:method GET, :status 200, :scheme https) compress to 1 byte each.

Response handling​

H3Response is a record (core/net/h3/request.vr:82) and these are FIELDS, read without parentheses:

  • status — UInt16 HTTP status code.
  • headers — List<QpackHeaderField> in emission order.
  • body — fully-buffered List<Byte> of the response body.
  • trailers — trailing header section, empty if the server sent none.

The only methods on it are the two constructors H3Response.ok(body) / H3Response.status(code), the chaining with_header(name, value), and to_field_list().

0-RTT resumption​

For repeat connections to the same origin:

The ticket half is not an H3 API

connect returns Result<H3Client, H3ClientError> — a client, not a (client, ticket) pair — and there is no save_ticket / load_ticket in core/. The session connect_resumed wants is a core.net.tls13.handshake.ClientSession, produced and carried by the TLS layer, and the early data is a &[Byte] you pass separately. The signature is connect_resumed(url: &Text, opts: ClientOptions, session: ClientSession, early_data: &[Byte]) (core/net/h3/client.vr:266). How a ClientSession is obtained and persisted between processes is the TLS stack's story, not this page's, and it is not written down anywhere yet.

// SHAPE ONLY for the resumption half — see the caution above.
let mut client = H3Client.connect_resumed(
&f"https://example.com", opts, session, early_data).await?;
let resp = client.get(&f"/dashboard").await?; // rides in 0-RTT

connect_resumed encodes the first request into the client's early data flight. The server MAY or MAY NOT accept 0-RTT — if rejected, the request is retried transparently after the full handshake.

Limits per RFC 8446 §4.2.10 are enforced via opts.max_field_section_size and the ticket's max_early_data_size.

Errors​

public type H3ClientError is
| UrlParse(UrlError)
| UnsupportedScheme(Text)
| Resolve(Text)
| Trust(TrustError)
| Transport(Text)
| Handshake(Text)
| H3Layer(H3Error)
| Timeout
| EarlyDataTooLarge(Int)
| Closed;

H3Layer(_) wraps the RFC 9114 §8.1 error codes — see h3_error_variants.

Cancellation and shutdown​

Every request runs inside the caller's nursery. Cancelling the outer task cleanly closes all outstanding streams and the underlying QUIC connection:

nursery.spawn(async {
let _ = client.get(&f"/slow").await;
});
Delay(Duration.from_secs(2)).await;
nursery.cancel(); // available request is aborted, QUIC closes

See also​