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 bytesSendFrame→ 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 timeSleep→ :func:asyncio.sleepReadResponse→ one frame, or a recorded timeoutAbort→transport.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.