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:
Abort→transport.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.
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.
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
rawleft-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
¶
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.