flare_mlake · API · quic

flare.quic.client

flare.quic.client -- QUIC v1 client connection driver.

The client-role mirror of :class:flare.quic.server.QuicListener's per-connection path, collapsed to a single connection over one connected UDP flow. It reuses every codec the server reuses -- packet headers (:mod:flare.quic.packet), frames (:mod:flare.quic.frame), the sans-I/O state machine (:class:flare.quic.state.Connection), Initial-level AEAD (:func:flare.quic.protection.protect_initial_packet / unprotect_initial_packet), and the rustls QUIC session for Handshake / 1-RTT protection -- and only adds the client-role bits: a client-chosen Initial DCID, the ClientHello first flight, client-direction Initial secrets (is_server=False), and the client packet-number spaces.

Handshake flow (RFC 9001 §4 + RFC 9000 §7)
  1. :meth:QuicClientConnection.start picks a random Initial DCID (the value both ends derive Initial secrets from, RFC 9001 §5.2) and a client Source CID, builds a :class:flare.tls.rustls_quic.RustlsQuicSession via the connector, drains the ClientHello, and emits the first Initial packet padded to >= 1200 bytes (RFC 9000 §14.1).
  2. :meth:poll drains the inbound socket queue: the server's Initial (ServerHello) decrypts off the Initial secret; its Handshake flight (EncryptedExtensions / Certificate / CertificateVerify / Finished) decrypts through the rustls session. Inbound CRYPTO feeds rustls; outbound CRYPTO (the client Finished) drains back out and ships in a Handshake packet. ACKs are emitted per packet-number space.
  3. Once rustls reports the handshake complete and installs 1-RTT keys, the connection is ESTABLISHED and the H3 layer drives request / response STREAM frames through :meth:send_stream / :meth:poll.
Scope / ceilings

This driver targets the common single-path, low-loss case the HTTP/3 client needs:

  • Minimal PTO-driven retransmission for the 1-RTT (request) path via :mod:flare.quic._loss_recovery: ack-eliciting 1-RTT packets are tracked, retired as ACKs arrive, and retransmitted on a probe timeout. This is not the full RFC 9002 implementation -- it omits ACK-based loss detection, an RTT estimator + congestion control, and handshake-level (Initial / Handshake CRYPTO) + server-side response retransmit. Those are tracked as a loss-recovery follow-up. During the handshake a dropped Finished is still recovered by the peer re-driving our flight.
  • STREAM sends are fragmented into path-MTU-sized packets. The caller pumps the connection when peer flow-control credit is exhausted.
  • The server's advertised transport parameters are decoded once the handshake completes (:meth:QuicClientConnection._apply_peer_transport_ params): the client clamps its egress datagram size to the peer's max_udp_payload_size and gates STREAM sends on the peer's per-stream / connection flow-control limits, including subsequent MAX_DATA / MAX_STREAM_DATA updates.

References:

  • RFC 9000 §7 "Cryptographic and Transport Handshake".
  • RFC 9001 §4-§5 "Packet Protection" + "Initial secrets".
  • RFC 9000 §14.1 "Initial datagram size" (>= 1200 bytes).

Structs

QuicClientConnection

struct QuicClientConnection

A single client-role QUIC connection over a connected UDP flow.

Owns the UDP socket, the rustls client session, and the sans-I/O :class:flare.quic.state.Connection. Construct via :meth:start (non-blocking: sends the first Initial) or :meth:connect (blocks until the handshake completes). After the handshake the H3 layer drives streams via :meth:open_bidi_stream, :meth:send_stream, and :meth:poll.

Implements Movable

Fields
conn: Connection: Sans-I/O connection state: per-stream + flow-control bookkeeping, reused unchanged from the server path.
session: RustlsQuicSession: The rustls client session: drives the TLS 1.3 handshake inside CRYPTO frames and holds the Handshake / 1-RTT AEAD keys.
sock: UdpSocket
peer: SocketAddr
initial_dcid: ConnectionId: Client-chosen Destination CID placed on the first Initial. Both ends derive Initial secrets from this value (RFC 9001 §5.2); it is the AEAD dcid for every Initial packet regardless of any later CID change.
first_dcid: ConnectionId: The DCID of our very first Initial, before any Retry. The server must echo it as original_destination_connection_id.
retry_scid: List[UInt8]: Source CID of the Retry we followed, empty if none.
server_scid: List[UInt8]: Source CID of the server's first Initial; the server must echo it as initial_source_connection_id.
dcid: ConnectionId: Outbound Destination CID for routing. Starts equal to :attr:initial_dcid; switches to the server's Source CID once the first server long-header packet is parsed (RFC 9000 §7.2).
scid: ConnectionId: Client Source CID -- our own identity. Its length is the pinned DCID length for parsing inbound short-header 1-RTT packets (RFC 9000 §17.3).
aead_choice: Int
max_udp_payload_size: Int
tx_initial_pn: UInt64
tx_initial_offset: UInt64
tx_handshake_pn: UInt64
tx_handshake_offset: UInt64
tx_1rtt_pn: UInt64
key_phase: Bool: 1-RTT key phase bit we send; flips on every key update (RFC 9001 sec 6).
tx_initial_crypto: List[UInt8]: Outbound Initial-level CRYPTO awaiting egress (the ClientHello before it is first sent; empty afterwards).
tx_handshake_crypto: List[UInt8]: Outbound Handshake-level CRYPTO (the client Finished).
tx_1rtt_crypto: List[UInt8]: Outbound 1-RTT-level CRYPTO (rare client post-handshake messages).
rx_initial_ranges: List[UInt64]
rx_handshake_ranges: List[UInt64]
rx_1rtt_ranges: List[UInt64]
rx_initial_ack_pending: Bool
rx_handshake_ack_pending: Bool
rx_1rtt_ack_pending: Bool
rx_initial_largest: UInt64
rx_handshake_largest: UInt64
rx_1rtt_largest: UInt64
reasm: _CryptoReasm
have_hs_keys: Bool
have_1rtt_keys: Bool
got_server_cid: Bool
established: Bool
retry_token: List[UInt8]: Address-validation token from an inbound Retry (RFC 9000 sec 8.1), echoed on every subsequent Initial. Empty until a Retry arrives.
retried: Bool: True once a Retry has been consumed; further Retries are ignored (RFC 9000 sec 17.2.5.2).
first_initial_crypto: List[UInt8]: Cached ClientHello CRYPTO so the Initial can be re-sent verbatim (with the token + new Initial keys) after a Retry.
next_bidi_stream: UInt64: Next client-initiated bidirectional stream id to hand out (RFC 9000 §2.1: client bidi ids are 0, 4, 8, ...).
next_uni_stream: UInt64: Next client-initiated unidirectional stream id to hand out (RFC 9000 §2.1: client uni ids are 2, 6, 10, ...). H3 opens three of these per connection: control + QPACK encoder/decoder.
send_offsets: Dict[UInt64, UInt64]: Per-stream cumulative send offset for outbound STREAM frames.
enable_0rtt: Bool: Opt-in TLS session resumption / 0-RTT (RFC 9001 §4.6). Off by default so the standard path is byte-for-byte unchanged. When the same :class:RustlsQuicConnector is reused for a later connection to a server that issued a 0-RTT-capable ticket, rustls resumes the session and (in-process only -- rustls 0.23 has no cross-process ticket export) exposes early keys.
ecn_rx: List[UInt64]: ECT(0), ECT(1) and CE counts of 1-RTT packets received, reported in ACK_ECN frames (RFC 9000 sec 13.4.1).

__init__

def __init__(out self, var conn: Connection, var session: RustlsQuicSession, var sock: UdpSocket, peer: SocketAddr, var initial_dcid: ConnectionId, var scid: ConnectionId, aead_choice: Int, max_udp_payload_size: Int)

start

def start(peer: SocketAddr, connector: RustlsQuicConnector, server_name: String, max_idle_timeout_ms: UInt64 = UInt64(30000), initial_max_data: UInt64 = UInt64(1048576), max_udp_payload_size: Int = Int(1452), aead_choice: Int = Int(0), enable_0rtt: Bool = False, max_datagram_frame_size: UInt64 = UInt64(0), token: List[UInt8] = List(), ecn: Bool = True) -> Self

Open a client connection and emit the first Initial (ClientHello) packet without blocking on the response.

Picks the Initial DCID + client SCID, builds the rustls client session with the encoded client transport parameters, drains the ClientHello, and ships the padded Initial datagram. Drive the handshake to completion with :meth:poll (or use :meth:connect for the blocking convenience).

max_datagram_frame_size > 0 accepts DATAGRAM frames up to that size (RFC 9221); they arrive in poll's events.datagrams. token is an earlier connection's :meth:address_token for the same server, which spares a Retry round trip (RFC 9000 sec 8.1.3). ecn marks packets ECT(0) and reports the server's marks (RFC 9000 sec 13.4; Linux).

connect

def connect(peer: SocketAddr, connector: RustlsQuicConnector, server_name: String, handshake_timeout_ms: Int = Int(5000), max_idle_timeout_ms: UInt64 = UInt64(30000), initial_max_data: UInt64 = UInt64(1048576), max_udp_payload_size: Int = Int(1452), aead_choice: Int = Int(0), enable_0rtt: Bool = False, max_datagram_frame_size: UInt64 = UInt64(0), token: List[UInt8] = List(), ecn: Bool = True) -> Self

Open a client connection and block until the QUIC + TLS handshake completes (or handshake_timeout_ms elapses).

Raises on timeout. On return the connection is ESTABLISHED and ready for :meth:send_stream.

enable_0rtt opts into session resumption (RFC 9001 §4.6): reuse the same :class:RustlsQuicConnector across connections so a later one resumes the cached session. Off by default.

poll

def poll(mut self, timeout_ms: Int = Int(100)) -> ConnectionEvents

Receive a burst of inbound datagrams, advance the handshake / stream state, flush pending ACKs + CRYPTO, and return the per-call :class:ConnectionEvents.

Blocks up to timeout_ms for the first datagram, then drains the rest of the socket queue non-blocking (up to :data:_POLL_BATCH) so one poll consumes a whole server flight. Returns empty events on a clean timeout.

open_bidi_stream

def open_bidi_stream(mut self) -> UInt64

Allocate the next client-initiated bidirectional stream id (RFC 9000 §2.1: 0, 4, 8, ...). The H3 request layer opens one bidi stream per request.

open_uni_stream

def open_uni_stream(mut self) -> UInt64

Allocate the next client-initiated unidirectional stream id (RFC 9000 §2.1: 2, 6, 10, ...). H3 opens three of these per connection (control + QPACK encoder/decoder).

send_stream

def send_stream(mut self, stream_id: UInt64, data: List[UInt8], fin: Bool)

Send data (with optional FIN) on stream_id, fragmenting across as many 1-RTT STREAM frames / packets as the path MTU requires and advancing the per-stream offset. An empty FIN-only send emits a single zero-length frame so a body-less request still closes its stream.

send_datagram

def send_datagram(mut self, payload: Span[UInt8]) -> Bool

Send payload now as an unreliable DATAGRAM (RFC 9221) in a packet of its own, never retransmitted (sec 5.2). False, and nothing sent, before the server's transport parameters are known, when it takes no DATAGRAMs or the frame exceeds its max_datagram_frame_size or one packet, or when the congestion window is full.

cancel_stream

def cancel_stream(mut self, stream_id: UInt64)

Cancel both directions of an HTTP/3 request (H3_REQUEST_CANCELLED).

send_capacity

def send_capacity(self, stream_id: UInt64) -> Int

Current flow-control credit for a client-initiated stream.

stream_reset

def stream_reset(self, stream_id: UInt64) -> Bool

release_stream_credit

def release_stream_credit(mut self, stream_id: UInt64, consumed: UInt64)

Replenish receive credit for newly consumed, ordered stream bytes.

send_stream_early

def send_stream_early(mut self, stream_id: UInt64, data: List[UInt8], fin: Bool)

Send data (with optional FIN) on stream_id at 0-RTT (EarlyData), before the handshake completes.

The 0-RTT counterpart of :meth:send_stream: same MTU-bounded STREAM-frame fragmentation and per-stream offset bookkeeping (it shares :attr:send_offsets), but each frame rides a :meth:_build_0rtt packet protected with the resumed connection's EarlyData keys. Requires :attr:_early_keys_ready (a resumed connection that installed 0-RTT keys -- see :meth:early_data_ready).

The full send is recorded in :attr:_early_flight so :meth:finish_early_data can replay it at 1-RTT if the server rejects early data. The emitted 0-RTT packets are registered with the loss-recovery tracker (see :meth:_build_0rtt), so a lost 0-RTT packet is retransmitted as 1-RTT once keys are up (the shared packet-number space makes this transparent).

On the reject path the registered 0-RTT packets are never acked (the server discarded them), so the PTO eventually re-sends their frames in addition to :meth:finish_early_data's explicit 1-RTT replay. Both carry identical stream offsets, so the receiver dedupes them -- the only cost is a few redundant packets on rejection. Upgrade path: a drop-by-pn on the loss tracker invoked from the reject branch.

finish_early_data

def finish_early_data(mut self) -> Bool

Resolve the 0-RTT flight after the handshake completes.

Call once the connection is ESTABLISHED. Returns True when the server accepted early data (the 0-RTT flight stands, nothing more to send) and False when it rejected it (RFC 9001 sec 4.6) -- in which case the recorded flight (:attr:_early_flight) is replayed at 1-RTT so the request still completes, transparently to the caller.

A rejected 0-RTT flight leaves no stream state on the server (rustls drops the early data), so the replay resends the identical STREAM frames on the same stream ids from offset 0: each distinct early stream's offset is reset, then every recorded send is re-emitted in order via :meth:send_stream (1-RTT). The H3 response reader keys on the stream id, so it is oblivious to which flight delivered the request.

keepalive

def keepalive(mut self)

Send a 1-RTT PING to keep a pooled-idle connection alive and elicit a server ACK (RFC 9000 §10.1.2). A no-op until 1-RTT keys are installed.

migrate

def migrate(mut self, rebind: Bool = True) -> Bool

Migrate the connection to a fresh network path.

Switches the active Destination CID to a spare the server granted via NEW_CONNECTION_ID (RFC 9000 §9.5 requires an unused CID per path so the two paths cannot be linked), optionally rebinds the local UDP socket to a new ephemeral port (the address change that defines a migration), and probes the new path with a PATH_CHALLENGE (§8.2). The peer echoes a PATH_RESPONSE which :meth:poll matches to mark :attr:path_validated. The old CID is retired.

Returns False if the connection is not established or the server has not granted a spare CID yet (no migration is possible without one). Use :meth:migrate_and_validate for the blocking convenience that polls until the new path is confirmed.

migrate_and_validate

def migrate_and_validate(mut self, rebind: Bool = True, timeout_ms: Int = Int(2000)) -> Bool

Migrate (see :meth:migrate) then block until the new path is validated by a PATH_RESPONSE or timeout_ms elapses. Returns True on a validated path.

initiate_key_update

def initiate_key_update(mut self)

Start a 1-RTT key update (RFC 9001 sec 6.1): later packets go out under the next key generation and the other phase bit.

address_token

def address_token(self) -> List[UInt8]

The server's latest NEW_TOKEN token (empty if none), to pass as token when connecting to it again (RFC 9000 sec 8.1.3).

is_established

def is_established(self) -> Bool

Whether the QUIC + TLS handshake has completed and 1-RTT keys are installed.

early_data_ready

def early_data_ready(self) -> Bool

Whether this connection resumed a session and rustls installed 0-RTT (EarlyData) keys (RFC 9001 §4.6). True only when :attr:enable_0rtt was set, the same connector was reused, and the server's prior ticket allowed early data.

When True, :meth:send_stream_early emits application STREAM frames in 0-RTT (EarlyData) packets and :meth:finish_early_data resolves acceptance after the handshake (replaying at 1-RTT on rejection).

early_data_accepted

def early_data_accepted(self) -> Bool

Whether the server accepted early data for this resumed connection (RFC 8446 §4.2.10; meaningful after the handshake completes). False on a fresh connection or when 0-RTT was not enabled. A client that sent 0-RTT data must replay it at 1-RTT when this is False.

path_validated

def path_validated(self) -> Bool

Whether the most recent :meth:migrate probe has been confirmed by a matching PATH_RESPONSE (RFC 9000 §8.2).

active_dcid_seq

def active_dcid_seq(self) -> UInt64

Sequence number of the peer Connection ID currently used as the active Destination CID (0 is the handshake CID).

peer_cid_count

def peer_cid_count(self) -> Int

Number of spare peer Connection IDs learned via NEW_CONNECTION_ID. A migration needs at least one.

alpn

def alpn(self) -> String

Negotiated ALPN identifier (e.g. "h3"). Meaningful only after the handshake completes.

local_addr

def local_addr(self) -> SocketAddr

The ephemeral local UDP address the client bound to.

shutdown

def shutdown(mut self)

Gracefully close: send a 1-RTT application-level CONNECTION_CLOSE (RFC 9000 sec 10.2, error 0) so the peer tears the connection down immediately instead of waiting out its idle timeout, then close the socket. A no-op send until 1-RTT keys are installed.

close

def close(mut self)

Close the underlying UDP socket. Idempotent.