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.
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.
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.
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
¶
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).