Skip to content

blackbull.client

blackbull.client

Async protocol clients.

Client selects HTTP/1.1 without TLS; name HTTP2Client explicitly for h2c. Clients own one connection for their async context. TimeoutError and raw WebSocket read failures do not all derive from ClientError; see docs/guide/client.md. Scenario exports alias blackbull.fault_injection.

Abort dataclass

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

Terminal: later steps do not run.

Client

ALPN-negotiating client: HTTP2Client on h2, else HTTP1Client.

With ssl=None (the default) there is no ALPN to read, so the answer is always HTTP/1.1. WebSocket is never dispatched to; name its client.

Use as an async context manager::

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

ClientConnection

Bases: ConnectionProtocol

The server's buffered protocol, plus a close a client can await.

wait_closed() async

Wait for connection loss; a cancelled wait can be retried.

ClientError

Bases: Exception

Base for protocol client errors; built-in TimeoutError is separate.

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 final head's regular fields as a Headers instance (bytes-keyed, lowercase-indexed). trailers is the trailer section that followed the body: RFC 9113 §8.1 makes it a separate field section, and gRPC puts grpc-status in it, so it is held apart rather than folded into headers where its provenance would be lost. body is the concatenation of all DATA-frame payloads received on the stream. ended_on_head is true when END_STREAM arrived on the final head's HEADERS frame, so no DATA frame and no trailer section followed — gRPC's Trailers-Only shape. It is always false on HTTP/1.1.

ConnectionError

Bases: ClientError

The connection is closed or unusable.

Fate

Bases: Enum

What a finished response leaves its connection fit for.

HTTP1Client

Bases: _TransportOwner

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 handoff; after that, the returned 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 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

Run connected-socket scenario steps and collect outcomes in ScenarioResult.

Peer and transport failures land on the result; a step outside the vocabulary raises TypeError and an exited client ConnectionError, both before any byte. Abort stops further steps. The vocabulary is docs/guide/fault_injection.md.

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, offered_protocols=None, timeout=None) async

Read one HTTP/1.1 response from the connection.

Optional timeout bounds the entire read (status line + headers + body). Raises asyncio.TimeoutError if the deadline is hit; the caller decides whether to treat that as a transport- fail or a normal protocol outcome. Supply the protocols offered with Connection: upgrade as offered_protocols to validate a 101 for handoff; without them, a 101 is returned but its transport is closed.

reset_wire_buffer()

Discard previously captured wire bytes.

send_body_bytes(data, *, byte_interval=0.0) async

Send body bytes with the same semantics as send_raw.

send_chunk(data) async

Send one Transfer-Encoding: chunked chunk.

Caller must have already emitted Transfer-Encoding: chunked via send_header_line and called end_headers. Finish the chunked stream with 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.

With byte_interval > 0 the bytes go out one at a time, that many seconds apart — the primitive a slowloris-style stall is built from. Each write is drained, so the pacing is on the wire and not 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.

Owns the request's framing: a byte body is sent under one Content-Length, an AsyncIterable body under that length when the caller declared one and under Transfer-Encoding: chunked when it did not. 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.

Framing belongs to the sender, so the whole field pair is decided here: a caller's Content-Length is checked against the body and then written once, canonically. A caller's Transfer-Encoding is honoured only where rewriting it cannot change what the body means — the value chunked, the one framing this client writes — and refused otherwise. A body whose length is known goes out as that many raw octets, a stream included, with the total checked as it goes; one whose length is not knowable up front is chunk-framed instead.

The return value is internal: pass it to send_prepared unchanged.

send_prepared(prepared) async

Write a request returned by prepare.

HTTP1ResponseRecipient

Bases: HTTP1ResponseHeadReader

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

Bases: _TransportClose

Bidirectional transport after CONNECT or an HTTP 101 switch.

Obtain one with 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

Bases: HTTP2Connection

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

Return this connection's shared HPACK encoder/decoder and header limit.

Every header producer, including extended CONNECT, must reuse this context.

attach_stream_reader(stream_id) async

Deliver stream_id's DATA to a reader from now on, crediting as it is consumed; frames already queued for the raw stream go first.

execute_scenario(scenario) async

Run HTTP/2 fault steps and collect peer/transport outcomes in the result.

Peer and transport failures land on the result; a step outside the vocabulary raises TypeError, an exited client ConnectionError and a scenario the connection cannot run RuntimeError, all before any byte. Abort stops further steps.

receive_raw_frame() async

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

The read-side twin of 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.

A fresh queue per call: registering the same stream twice is a programming error, not a second subscription. The depth is client_raw_queue_depth, which flow control cannot stand in for.

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: this is teardown, nothing here needs the connection, and a cleanup path that raises after close turns an orderly shutdown into an error. What the peer still sends on the stream is ignored as after our own reset (§5.1).

HandshakeError

Bases: ClientError

A WebSocket or HTTP/2 handshake failed.

ProtocolError

Bases: ClientError

A request or peer response violates the protocol.

ReadResponse dataclass

Read one message: an HTTP/1.1 response, or one HTTP/2 frame.

On timeout the scenario ends with timed_out set and the step uncounted. End of stream sets peer_closed and the scenario goes on.

ResponseTooLarge

Bases: ClientError

A response head or body exceeds a configured client budget.

This is a local limit, distinct from a protocol violation. Raise the budget only if the caller can safely accept more data.

Scenario dataclass

A sequence of steps the executor walks against one connection.

from_bytes(raw) classmethod

Decode every byte string into a runnable fuzz scenario, including empty input.

Truncated input returns the completed steps. Abort discards remaining bytes; SEND payload lengths are uint16. This decoder must not raise on arbitrary input.

from_json(src) classmethod

Parse what to_json wrote.

to_json()

Serialise to JSON Lines (see docs/guide/fault_injection.md).

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

Collect scenario outcomes; fields are not mutually exclusive.

A timeout may precede a later response; response is newest, received holds all.

SendRawBytes dataclass

Send arbitrary bytes, for anything a typed step will not build.

byte_interval > 0 sends one byte at a time with that delay.

Sleep dataclass

Idle for duration seconds without reading or writing.

StreamReset

Bases: ClientError

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

WebSocketClient

Bases: _TransportOwner

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

Upgrade to WebSocket, closing the connection on failure.

response_timeout=None disables the handshake response deadline.

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 connect and returns a 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 connect has not run yet.

frame_factory property

Reuse the HTTP2Client connection's HPACK context.

Raise RuntimeError before entering the connection context.

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

Run the RFC 8441 Extended CONNECT handshake on this connection.

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

WebSocketH2Session

Bases: _WebSocketSession

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 HTTP2StreamReader — 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.

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 TimeoutError if no message arrives within timeout. Returned opcodes match blackbull.server.ws_codec.WSOpcode; a peer CLOSE (or stream end) is surfaced as (WSOpcode.CLOSE, 2-byte code).

WebSocketSession

Bases: _WebSocketSession, _TransportClose

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.

__aenter__() async

Return the already-connected session; the handshake ran before this.

ping(data=b'') async

Send a PING carrying data; the peer's PONG arrives on the read path.

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.

send_bytes(data) async

Send data as a binary message.

send_text(text) async

Send text as a UTF-8 text message.

__getattr__(name)

Deprecated SendBytes alias for SendRawBytes (BBD005).