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 (HTTP/1.1 persistent connections, RFC 7230 §6.3) until __aexit__ closes it. 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.

read_response(*, 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.

HTTP1ResponseRecipient

Reads an HTTP/1.1 response from an AbstractReader.

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

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.

receive_raw_frame() async

Escape hatch: read one raw frame; bypasses the receive loop.

Only safe to call when the receive loop is not running (i.e. before __aenter__ finishes or after the loop has been cancelled).

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

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.

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.

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.

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 legacy diff_*.txt corpus loader and by the Hypothesis well_formed_scenario_strategy.

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.

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=()) 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.

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.

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.