Skip to content

blackbull.client

blackbull.client

Abort dataclass

Hard-close the connection (transport.abort → RST on Linux).

Distinct from the graceful writer.close() / wait_closed() in HTTP1Client.__aexit__. Subsequent steps short-circuit; the executor stops walking the scenario after an Abort.

Client

ALPN-negotiating client.

Picks HTTP2Client if the server advertises h2, else HTTP1Client. With ssl=None (the default), no ALPN is performed and HTTP/1.1 is used — h2c (HTTP/2 over plaintext) is supported by HTTP2Client directly, but the dispatcher only triggers it when ALPN selects h2.

Use as an async context manager::

async with Client('localhost', 8000) as c:
    res = await c.request(HTTPMethod.GET, '/')
    # `c` is an HTTP1Client when ssl=None, or whichever the ALPN handshake chose.

ClientError

Bases: Exception

Base class for all client-side errors.

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.

ConnectionError

Bases: ClientError

The connection was closed unexpectedly (e.g. server sent GOAWAY).

HTTP1Client

Async HTTP/1.1 client.

Use as an async context manager::

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

The connection persists across multiple request() calls when HTTP version, Connection options, and body framing permit it. A successful CONNECT or 101 can be transferred with :meth:handoff; after that, the returned :class:HTTP1UpgradeSession owns transport closure. Pass ssl= to use TLS.

The Host header is injected automatically when the caller omits it.

wire_buffer property

Bytes sent so far by the low-level primitives in this session.

Empty unless the client was constructed with record_wire_bytes=True. Reset with :meth:reset_wire_buffer.

end_chunked() async

Emit the size-0 terminator chunk that closes a chunked body.

end_headers() async

Emit the bare CRLF that terminates the header block.

execute_scenario(scenario) async

Walk scenario.steps against the connected socket.

Never raises. Every outcome (response, timeout, transport failure, hard-abort) is folded into the returned :class:ScenarioResult so callers can categorise without try/except boilerplate per scenario.

Step dispatch
  • :class:SendBytes → :meth:send_raw
  • :class:Sleep → :func:asyncio.sleep
  • :class:ReadResponse → :meth:read_response
  • :class:Aborttransport.abort() (RST on Linux); walks no further steps.

handoff()

Transfer a completed CONNECT/101 transport to a raw session.

The returned session preserves bytes already buffered by the HTTP reader and exposes both read and write sides. This operation is available exactly once. If the client context exits before handoff, the client closes the transport; after handoff, the session owns it.

read_response(*, request_method=None, timeout=None) async

Read one HTTP/1.1 response from the connection.

Optional timeout bounds the entire read (status line + headers + body). Raises :class:asyncio.TimeoutError if the deadline is hit; the caller decides whether to treat that as a transport- fail or a normal protocol outcome.

reset_wire_buffer()

Discard previously captured wire bytes.

send_body_bytes(data, *, byte_interval=0.0) async

Send body octets to the peer.

Same semantics as :meth:send_raw, kept separate for readability at call sites that frame headers separately from the body.

send_chunk(data) async

Send one Transfer-Encoding: chunked chunk.

Caller must have already emitted Transfer-Encoding: chunked via :meth:send_header_line and called :meth:end_headers. Finish the chunked stream with :meth:end_chunked.

send_header_line(name, value) async

Emit one Name: Value\r\n header line with no dedup or validation. Callers wanting a duplicate Content-Length or a header value containing arbitrary bytes use this primitive directly.

send_raw(data, *, byte_interval=0.0) async

Push arbitrary bytes onto the underlying socket.

When byte_interval > 0 the bytes are transmitted one at a time with byte_interval seconds between writes — the primitive slowloris-style stall the differential tests rely on. Each per- byte write is followed by drain() (inherited from :class:AsyncioWriter), so the bytes actually leave the socket on schedule rather than accumulating in the asyncio send buffer.

send_request_line(method, target, *, version=b'HTTP/1.1') async

Emit METHOD<SP>TARGET<SP>HTTP/1.1\r\n with no validation.

Accepts arbitrary bytes for method/target/version so a test can deliberately send b"BREW", lowercase versions, or garbage tokens. No automatic Host or Content-Length injection — the caller drives the wire bit by bit.

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

Send a request and yield body chunks lazily.

Unlike request() this does not buffer the response body, so gigabyte-sized responses do not need to fit in memory. Status and headers are not exposed by this method; use request() if you need them.

HTTP1RequestSender

Writes an HTTP/1.1 request — request line, headers, body — to an AbstractWriter.

Adds Content-Length automatically for fixed-size byte bodies; switches to Transfer-Encoding: chunked for AsyncIterable bodies. The Host header MUST be present (RFC 7230 §5.4) — the helper raises ProtocolError if it is not.

prepare(method, path, headers, body=b'') classmethod

Validate and render everything that can fail before wire I/O.

The high-level client claims response ownership only after this method succeeds. A caller error such as a mismatched Content-Length or an unencodable request target therefore cannot retire an untouched keep-alive connection.

send_prepared(prepared) async

Write a request returned by :meth:prepare.

HTTP1ResponseRecipient

Reads an HTTP/1.1 response from an AbstractReader.

Decodes Content-Length-bound, Transfer-Encoding: chunked, and connection-close-delimited bodies. Returns a ClientResponse; stream() returns an async iterator of body chunks instead, so large responses don't have to fit in memory — true of all body framings.

receive(reader, *, method=None, skip_interim=True) async

Read one final response, optionally exposing an interim response.

Production callers keep skip_interim=True and therefore pass over 100/102/103 responses until the final response. 101 is final by protocol-switch semantics. The low-level fault-injection API passes skip_interim=False so each peer message remains observable.

method= overrides the constructor's, for a caller that knows it only at the read.

HTTP1UpgradeSession

Bidirectional transport after CONNECT or an HTTP 101 switch.

Obtain one with :meth:HTTP1Client.handoff. The handoff is one-shot: once returned, this session owns transport closure and the originating HTTP client can no longer read or write the connection.

close() async

Close the transferred transport. Idempotent.

read(n=-1) async

Read switched-protocol bytes, including HTTP read-ahead bytes.

write(data) async

Write raw switched-protocol bytes.

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.

HandshakeError

Bases: ClientError

A WebSocket or HTTP/2 handshake failed.

ProtocolError

Bases: ClientError

The client refused to send a request that violates the protocol.

ReadResponse dataclass

Read one HTTP/1.1 response from the connection.

timeout bounds the entire status-line + headers + body read. On timeout the executor records the outcome on the :class:ScenarioResult and does not raise — the caller decides whether to treat that as a transport-fail or normal outcome.

ResponderFactory

Looks up the Responder for an incoming frame type and instantiates it.

ResponseTooLarge

Bases: ClientError

The peer's response head passed a byte budget the client set.

Distinct from :class:ProtocolError: the response was well-formed as far as it was read. What failed is a limit this client chose, so a caller that wants the peer's output anyway can raise the budget rather than conclude the peer is broken.

Scenario dataclass

A sequence of steps the executor walks against one connection.

from_bytes(raw) classmethod

Decode arbitrary bytes into a scenario.

Total function: every byte string yields a valid scenario, including the empty string (→ empty scenario). Designed so atheris's coverage-guided byte mutations always produce runnable input — the fuzzer never spends cycles on parser errors.

Encoding:

  • The decoder walks raw left-to-right. At each position the next byte selects an opcode via % 4 (every byte value is therefore a legal opcode tag).
  • Each opcode then consumes a small payload from the following bytes. If the payload is short (end of input), decoding stops cleanly and the partial scenario is returned.

Opcode layout::

byte % 4 == 0  → SEND
    next 2 bytes (big-endian uint16) = length;
    next ``length`` bytes = data;
    next 1 byte (% len(_BYTE_INTERVAL_TABLE))
      → byte_interval.
byte % 4 == 1  → SLEEP
    next 1 byte (% len(_SLEEP_TABLE)) → duration.
byte % 4 == 2  → READ
    next 1 byte (% len(_TIMEOUT_TABLE)) → timeout.
byte % 4 == 3  → ABORT
    no payload.  Remaining bytes are discarded — an
    Abort short-circuits execution anyway, so it's the
    natural terminator.

Bounded payload sizes (uint16 length) keep individual scenarios well under 64 KiB, which is what we want for per-iteration fuzz throughput.

from_json(src) classmethod

Parse JSON Lines back to a :class:Scenario.

Skips blank lines so files that end with a trailing newline (the conventional git-friendly shape) parse cleanly.

A HEADER line carries the name. It is optional on the way in: corpus files written before the header existed have no such line and still parse, yielding an unnamed scenario.

to_json()

Serialise to JSON Lines: one {"op": ..., ...} per line.

Bytes payloads are base64-encoded so the result round-trips through stdout / git / json.loads without escape ambiguity. Round-tripped by :meth:from_json.

The scenario's name rides the first line under the op HEADER, the convention the other three vocabularies use, so the file stays one line-oriented stream with no out-of-band metadata. Without it a round trip silently dropped the name, and a catalogue case that came back anonymous cannot say which case it is.

well_formed(raw_request, *, response_timeout=5.0) classmethod

Wrap a complete raw HTTP/1.1 request as a one-shot scenario.

Equivalent to "send these bytes, then read one response". Used by the Hypothesis well_formed_scenario_strategy in the HTTP/1.1 differential suite.

ScenarioResult dataclass

Outcome of one :meth:HTTP1Client.execute_scenario call.

Exactly one of response / exception / timed_out / aborted is the meaningful field; the others are None / False. The executor never raises, so callers (differential test, fuzz harness) categorise on this object instead of writing try/except boilerplate per scenario.

SendBytes dataclass

Push raw bytes onto the connection.

byte_interval > 0 transmits one byte at a time with that delay between bytes — the primitive slowloris-style stall that lets scenarios express trickled headers or trickled bodies without dropping to a raw asyncio socket.

SendRawBytes dataclass

Push raw bytes onto the connection.

byte_interval > 0 transmits one byte at a time with that delay between bytes — the primitive slowloris-style stall that lets scenarios express trickled headers or trickled bodies without dropping to a raw asyncio socket.

Sleep dataclass

Idle for duration seconds without sending or reading.

Useful for post-headers idle, mid-keep-alive idle, and pre-response stall scenarios where the server is expected to time out and close.

StreamReset

Bases: ClientError

The HTTP/2 stream was reset by the peer (RST_STREAM).

WebSocketClient

Async WebSocket client.

Use as an async context manager::

async with WebSocketClient('localhost', 8000) as c:
    ws = await c.connect('/path', subprotocols=[b'chat'])
    await ws.send_text('hello')
    msg = await ws.receive()
    await ws.close()

The transport is held open between connect() and __aexit__; only one concurrent session per WebSocketClient is supported.

connect(path, *, subprotocols=(), response_timeout=5.0) async

Run the HTTP/1.1 Upgrade: websocket handshake on this connection.

Returns a WebSocketSession once the server has confirmed the upgrade with HTTP 101 and a valid Sec-WebSocket-Accept header. Raises HandshakeError on any handshake-time failure, or TimeoutError if no response arrives within response_timeout.

A peer can accept the connection and then never send the 101, so the transport-level deadline does not cover this wait. None opts out.

WebSocketH2Client

Async WebSocket-over-HTTP/2 client (RFC 8441).

Owns the TLS + HTTP/2 connection; performs the Extended CONNECT handshake (:method=CONNECT, :protocol=websocket) on :meth:connect and returns a :class:WebSocketH2Session for post-handshake frame I/O.

The peer server must advertise SETTINGS_ENABLE_CONNECT_PROTOCOL=1; BlackBull's server does so when BB_H2_ENABLE_WEBSOCKET=1.

connect_status property

:status from the last Extended CONNECT response, or None if :meth:connect has not run yet.

frame_factory property

The connection's HPACK context — the HTTP2Client's own.

Extended CONNECT rides an ordinary HTTP/2 connection that may already be carrying request() traffic, and RFC 9113 §4.3 gives that connection one dynamic table in each direction. Reading the context from the connection is what keeps the CONNECT's header block and every other block on the wire encoded against the same table the peer's single decoder is building.

Raises :class:RuntimeError before __aenter__: the context comes into being with the connection, so there is none to hand out yet.

connect(path='/', *, response_timeout=5.0) async

Run the RFC 8441 Extended CONNECT handshake on this connection.

Returns a :class:WebSocketH2Session bound to the new H2 stream. Raises :class:HandshakeError on a non-200 :status response or :class:TimeoutError if no response arrives within response_timeout.

WebSocketH2Session

Frame-level WebSocket session over one HTTP/2 stream.

Outgoing frames are masked (RFC 6455 §5.1) and wrapped in H2 DATA frames. Incoming DATA payloads feed the shared WebSocketRecipient stack through :class:_H2QueueReader — fragmentation reassembly, FIN/RSV/mask validation, UTF-8 checks, and auto-PONG all come from the same codec the server and the H1 client use.

close(code=_CLOSE_CODE_NORMAL, *, drain_timeout=5.0) async

Send a WebSocket close frame (END_STREAM on the carrying DATA frame), await the peer's echoed CLOSE bounded by drain_timeout, and stop the recipient's reader task. Idempotent.

Same close discipline as the H1 client's :meth:WebSocketSession.close.

receive(timeout=5.0) async

Return (opcode, payload) for the next complete WebSocket message on this stream (fragmented messages are reassembled; PING is answered transparently).

Raises :class:TimeoutError if no message arrives within timeout. Returned opcodes match :class:blackbull.server.ws_codec.WSOpcode; a peer CLOSE (or stream end) is surfaced as (WSOpcode.CLOSE, 2-byte code).

WebSocketSession

Frame-level WebSocket session over an established connection.

Always masks outgoing frames (RFC 6455 §5.1). Reads use WebSocketRecipient(require_masked=False) because servers MUST NOT mask their outgoing frames.

close(code=_CLOSE_CODE_NORMAL, *, drain_timeout=5.0) async

Send a CLOSE frame, await the peer's echoed CLOSE (bounded by drain_timeout), and stop the background reader task.

RFC 6455 §7.1.2 — the closing handshake is complete once both endpoints have sent and received a Close frame. A silent peer cannot hang close(): after drain_timeout the reader is shut down regardless. Data frames still in flight during the drain are discarded. Idempotent.

receive() async

Read one ASGI websocket.* event from the connection.

Returns one of
  • {'type': 'websocket.receive', 'text': str, 'bytes': None}
  • {'type': 'websocket.receive', 'text': None, 'bytes': bytes}
  • {'type': 'websocket.disconnect', 'code': int}

Server-initiated PING frames are auto-PONGed by the underlying WebSocketRecipient (with masking, since this session is the client). Server PONG frames are silently dropped.