Skip to content

blackbull.client.http2

blackbull.client.http2

HTTP/2 client (RFC 7540).

HTTP2Client opens a single TCP/TLS connection, sends the connection preface and an initial SETTINGS frame, then drives request/response exchanges over multiple concurrent streams.

The client is intended for wire-level testing of BlackBull's ASGIServer rather than as a feature-rich application client.

ClientResponse dataclass

A complete HTTP response received by the client.

status is the HTTP status code (parsed from the :status pseudo-header). headers are the regular response headers as a Headers instance (bytes-keyed, lowercase-indexed). body is the concatenation of all DATA-frame payloads received on the stream.

HTTP2Client

Async HTTP/2 client.

Use as an async context manager::

async with HTTP2Client('localhost', 8000) as c:
    res = await c.request(HTTPMethod.GET, '/')

ssl=None (the default) selects plaintext h2c. Provide an ssl.SSLContext with set_alpn_protocols(['h2']) for h2 over TLS.

Multiple request() calls share the same connection: each gets its own odd, monotonically-increasing client-initiated stream ID (RFC 7540 §5.1.1) and the responses are demultiplexed by the receive loop.

frame_factory property

This connection's one HPACK context (RFC 7541 §2.3, RFC 9113 §4.3).

The dynamic table is connection state and the peer keeps a single decoder for it, so every header block written to this connection has to come from this one encoder. A second FrameFactory on the same connection keeps a second table that diverges the moment the two interleave, and the peer then resolves one stream's index against a field the other inserted. Anything that frames on this connection — including the RFC 8441 WebSocket client layered over it — takes its factory from here rather than building one.

It also carries the decoder's max_header_list_size, so a second factory would decode inbound blocks without the cap this connection advertises.

execute_scenario(scenario) async

Walk scenario.steps against the connected socket.

The HTTP/2 counterpart of :meth:blackbull.client.http1.HTTP1Client.execute_scenario, and deliberately the same shape: every outcome — a frame read, a timeout, a transport failure, a hard-abort — is folded into the returned result so callers categorise without a try/except per scenario. What is raised instead is the scenario this connection cannot express at all: an unowned step (see :meth:_check_scenario_ownership) or a client whose context has exited. Neither is news about the peer, and folding one would make a closed client indistinguishable in the result from a silent server.

Step dispatch
  • SendPreface → the RFC 9113 §3.4 preface bytes
  • SendFrame → one frame, assembled here rather than by the production sender (which is what lets a scenario declare a length its payload does not match)
  • SendBytes → arbitrary bytes, optionally one at a time
  • Sleep → :func:asyncio.sleep
  • ReadResponse → one frame, or a recorded timeout
  • Aborttransport.abort(); walks no further steps

This lives on the client rather than in fault_injection because its twin does: a scenario executor needs the connection, and the client is what owns one.

receive_raw_frame() async

Escape hatch: read one raw frame, bypassing the receive loop's dispatch.

For negative-path / fault-injection tests and raw-frame clients that need a peer frame _receive_loop would otherwise route through the normal dispatcher — the read-side twin of :meth:send_raw_frame.

Only safe to call when the receive loop is not running (i.e. before __aenter__ finishes or after the loop has been cancelled); a concurrent loop would race this call for the reader.

Inherits client_h2_max_frame_size: one rule, not a second path around it. A scenario needing an over-sized frame opts out with BB_CLIENT_H2_MAX_FRAME_SIZE=0.

Refused after the context exits — the wait for a frame to begin is deliberately unbounded, so on a closed connection this parks for the life of the process.

register_raw_stream(stream_id)

Mark stream_id as a raw-frame stream.

Frames arriving on this stream are pushed into the returned asyncio.Queue instead of being routed through the request/response state machine. Used by :class:blackbull.client.WebSocketH2Client to receive WebSocket frames (carried in DATA frames after RFC 8441 Extended CONNECT) without racing the receive loop.

Returning a fresh queue each call is intentional — registering the same stream twice would be a programming error.

The depth is client_raw_queue_depth. Flow control does not substitute for it: most of what lands here is not flow-controlled, and RFC 9113 §6.9.1 charges a DATA frame's payload only, so a zero-length one costs the peer no credit at all.

request(method, path, *, headers=(), body=b'') async

Send one request and await the matching response.

Adds :authority automatically from host:port. Header names and values may be str or bytes; they are normalised to ASCII str for HPACK encoding.

send_raw_frame(frame) async

Escape hatch: write a raw frame to the wire (negative-path tests).

Refused after the context exits: the transport is closed, so the write is discarded by asyncio without a word and the caller is told its frame reached the peer.

unregister_raw_stream(stream_id)

Stop routing frames for stream_id into its raw-frame queue.

The one door a closed client still admits, and deliberately: this is teardown, WebSocketH2Session.close calls it from a finally, and a cleanup path that raises after close turns an orderly shutdown into an error. Nothing here needs the connection.