The async client¶
blackbull.client is BlackBull's own protocol-layer HTTP client: pure Python,
the same wire code the server uses, driven from the caller's side. It exists so
that BlackBull can be tested against its own bytes and pointed at deliberately
broken peers, and it is exported because those are useful jobs outside the test
suite too.
The generated API reference — the blackbull.client pages under API in the
navigation — says what each name takes and returns. This page answers the four
things it cannot: which client to construct, what an async with block owns,
what a call can raise, and what the defaults do and do not bound.
If you are here to write a test, start with Testing — it has the fixture, the four-client selection table, and worked round-trips. This page is what you read when one of those clients surprises you.
Choosing a client¶
Testing carries the selection table. Two things it leaves out are worth having in front of you before you pick.
Client never gives you h2c¶
Client picks a protocol by reading the negotiated ALPN protocol off the TLS
object. With the default ssl=None there is no TLS, therefore no ALPN,
therefore nothing to read — and the dispatcher falls back to HTTP/1.1. It is
not that h2c is unsupported; HTTP2Client speaks it directly. It is that the
dispatcher has no way to select it.
from http import HTTPMethod
from blackbull.client import Client, HTTP1Client, HTTP2Client
# No TLS, so no ALPN: the dispatcher hands back HTTP/1.1.
async with Client('127.0.0.1', port) as c:
assert isinstance(c, HTTP1Client)
# h2c on that same plaintext port — name the client instead of asking.
async with HTTP2Client('127.0.0.1', port) as c:
res = await c.request(HTTPMethod.GET, '/')
So: pass a TLS context and let ALPN decide, or name the protocol yourself.
Reaching for Client on a plaintext port to "let it work out" silently gets
you HTTP/1.1 every time.
The two clients are not the same size¶
They share request() and a ClientResponse, and diverge past that:
HTTP1Client.stream()has no HTTP/2 counterpart. It yields body chunks without buffering, which is how a response larger than memory is read. Every HTTP/2 response body is accumulated whole — which is whyBB_CLIENT_BODY_MAX_TOTALmatters more there (see below).body=accepts an async generator on HTTP/1.1 only.HTTP2Client.requesttakesbytes.- HTTP/1.1 adds the raw-wire primitives —
send_request_line,send_header_line,send_chunk,wire_buffer,handoff— and HTTP/2 adds the frame ones:frame_factory,send_raw_frame,register_raw_stream. WebSocketClient(RFC 6455) andWebSocketH2Client(RFC 8441) are separate front doors. WebSocket is deliberately outside ALPN dispatch: it is an HTTP/1.1 upgrade or an Extended CONNECT, not something a handshake picks for you.
A client is one connection¶
Every client is an async context manager over exactly one transport. There is
no pool, no reconnect, and no request-level retry. host and port are fixed
at construction, so a redirect to another origin is yours to follow.
__aenter__ opens the transport under connect_timeout (30 s by default;
Testing covers the number and
how to opt out) and then does whatever the protocol owes on connect. For
HTTP/1.1 that is nothing. For HTTP/2 it is the connection preface, one
SETTINGS frame, and a background receive loop — which is why an HTTP2Client
has a task running whether or not you have a request in flight, and why
scenario_mode exists to suppress all of it.
Requests inside the block reuse that connection. On HTTP/1.1 they are
sequential and persistence is negotiated per exchange; Connection: close from
either end retires it. On HTTP/2 they are concurrent streams on one connection,
so asyncio.gather over request() is the normal shape rather than a trick.
__aexit__ is terminal, and it does not wait for in-flight work. The
HTTP/2 client cancels its receive loop first, then fails every response still
pending with ConnectionError('client connection closed') — so a gather
abandoned by an exception elsewhere in the block raises rather than hanging.
Both clients then close the transport best-effort. A closed client cannot be
re-entered: the next call raises ConnectionError, naming the close you
performed rather than blaming the peer.
The one exception is handoff(), which transfers a CONNECT or 101 transport to
an HTTP1UpgradeSession and moves ownership of closing it with it — see
Testing.
How a request frames its body¶
Framing is the client's to decide — the rule the
server applies to a response in the other
direction. Content-Length is a statement about the body you passed to this
call, so it is checked against it and then written once, canonically. A
Transfer-Encoding of your own is accepted only when it is exactly chunked,
and refused otherwise. Accepting it does not echo it: a byte body still goes
out under Content-Length, and only a body that has to be streamed is written
chunked.
body= |
what goes out |
|---|---|
bytes, non-empty |
one Content-Length: <len>, raw octets |
bytes, empty, on POST / PUT / PATCH / DELETE |
Content-Length: 0 |
bytes, empty, on any other method |
no framing field |
async iterable, with a Content-Length |
that one field, raw octets, total checked |
| async iterable, without | Transfer-Encoding: chunked |
A Content-Length you pass in survives the empty cases too, so POST with an
explicit Content-Length: 0 still sends it.
A Content-Length you supply is honoured only where it can be guaranteed. On a
byte body that means it equals len(body). On an async iterable it is kept and
the stream is written raw — which is how an upload of known size goes out without
being buffered. The running total is checked, so a stream that ends short or runs
over raises ProtocolError and retires the connection: the request on the wire
no longer matches its head.
All of these are refused before a single octet is written, so the connection
goes on carrying the next request: two Content-Length fields that disagree, a
value that is not 1*DIGIT, a length that contradicts the body, and a
Transfer-Encoding the client would have to rewrite. That last one covers
anything but a lone chunked — a coding the caller applied itself
(gzip, chunked), a parameter, chunked twice — and any Transfer-Encoding
beside a Content-Length, which RFC 9112 §6.2 forbids a message from carrying.
A body you compressed yourself goes under Content-Encoding, framed by a
Content-Length or by Transfer-Encoding: chunked; Transfer-Encoding: gzip
is refused rather than quietly delivered as the payload. None of this touches
the raw-wire primitives in
Driving a misbehaving peer — send_header_line
will put two Content-Length lines on the wire, because that is what it is for.
What counts as a response¶
Under HTTP/2, request() returns only a well-formed final response. The
order is RFC 9113 §8.1's — zero or more informational 1xx heads, one final
head, the body, then a single optional trailer section — and every part is
checked: :status is present and three ASCII digits, content appears only
where RFC 9110 §9.3 allows it (a HEAD response and 204 / 304 carry
none, whatever they declare), a declared Content-Length matches the body
exactly, and a trailer section carries no pseudo-header field and no
Content-Length. A response that breaks one of these raises ProtocolError.
A hop-by-hop field such as Transfer-Encoding is refused earlier and
separately, by the frame layer, as a StreamReset.
The HTTP/1.1 reader is the laxer of the two: it reads no body for HEAD /
204 / 304 and refuses a malformed status line, but it cannot refuse
octets a peer sends where content should not be.
A refusal under HTTP/2 takes the stream alone — RST_STREAM, the connection
and its other streams survive. res.headers holds the final head's fields
only and res.trailers the trailer section: RFC 9113 §8.1 keeps the two
field sections apart, and gRPC puts grpc-status in the trailer. Folding
them together, as this client did, left a caller unable to tell which
section a field came from. The HTTP/1.1 client discards trailers as it reads
them, so res.trailers is always empty there. Informational heads are read
and discarded, which is what the HTTP/1.1 reader has always done with them.
res.ended_on_head is true when END_STREAM arrived on the final head's
HEADERS frame — gRPC's Trailers-Only shape — and false when an empty DATA
frame ended the stream, which leaves the same headers and body. It is always
false on HTTP/1.1.
What a call raises¶
Everything the client itself refuses derives from ClientError:
ClientError
├── ProtocolError the peer's message was malformed, or ours would be
├── ConnectionError the transport went away, or this client closed it
├── ResponseTooLarge a byte budget of ours was passed (carries .seen)
├── HandshakeError a WebSocket or HTTP/2 handshake failed
└── StreamReset the peer sent RST_STREAM (carries .stream_id, .error_code)
Roughly: ProtocolError and ResponseTooLarge come out of reading a response
on either protocol, and ProtocolError also out of building a request whose
framing would be ambiguous — see
How a request frames its body;
ConnectionError out of any call once the peer has gone or
the context has exited; StreamReset only from HTTP/2, only from the
request() whose stream was reset; HandshakeError only from
WebSocketClient.connect() and WebSocketH2Client's Extended CONNECT.
A stall is not a ClientError, on purpose¶
Every time bound in the client — the connect deadline, the head and body
deadlines — raises a bare TimeoutError. That is a deliberate split, and it is
the useful one when you are diagnosing a peer: a peer that stalled and a peer
that answered wrongly are different bugs, and catching them together throws
away the distinction.
from blackbull.client import ClientError
try:
res = await client.request(HTTPMethod.GET, '/report')
except TimeoutError:
... # the peer went quiet — nothing was refused
except ClientError:
... # the peer spoke, and what it said was refused
ClientError is the right granularity when you do not intend to act
differently per cause — a test asserting "this peer is not usable", a retry
wrapper. Catch the leaves when the recovery differs: raising a budget for
ResponseTooLarge is a sensible response to it and a nonsensical response to
ProtocolError.
The WebSocket read path is outside the hierarchy¶
Worth knowing before you write except ClientError around a WebSocket loop:
the client's WebSocket reader is the server's WebSocketRecipient, and it
raises blackbull.server.recipient.ProtocolError, which is not a
ClientError. The name collides with blackbull.client.ProtocolError and the
classes are unrelated.
So a frame that breaches BB_CLIENT_WS_MAX_FRAME_PAYLOAD, a message that
breaches BB_CLIENT_WS_MAX_MESSAGE_SIZE, invalid UTF-8 in a TEXT message, or a
fragmented control frame all surface from session.receive() as an exception
except ClientError will not catch. The disconnect event is delivered first and
the exception on the receive after it. Until the two hierarchies are joined,
catch Exception around a WebSocket receive loop, or import the server class
explicitly and know which one you have.
What the client waits for¶
The client's timeout design is one idea applied at two layers, and reading it as one rule is how people end up surprised by it.
At the connection, a peer may be silent for as long as it likes. The HTTP/2 frame reader never bounds the wait for the next frame to begin. It must not: server-sent events, a streaming response, a quiet multiplexed connection — all of those are a peer behaving correctly. What it does bound is the wait for a frame to finish: once nine header bytes have arrived the peer has committed to a payload length, so silence after that is an abandoned frame rather than an idle connection, and the read ends the way EOF does.
At a stream that has asked a question, both waits are bounded. A response runs one clock through two consecutive phases, never both at once:
| phase | runs from | until | bound |
|---|---|---|---|
| head | the request is fully on the wire | the final (>= 200) response head |
BB_CLIENT_HEAD_TIMEOUT |
| body | that head | the response ends | BB_CLIENT_BODY_TIMEOUT |
The head phase starts only once the request is fully sent — a send parked on
our own flow-control window is our backpressure, not the peer's silence. A
1xx interim response neither stops the head clock nor starts the body one:
it announces that the peer is still working, which is the case the deadline is
there for. The body phase is re-armed by each DATA frame that delivers payload,
so it bounds a gap, not a duration: a long response may outlast the deadline
many times over as long as no single gap does. It is per stream, because a
connection-wide clock is reset by any traffic and a busy stream would shelter a
stalled one.
The consequence to plan for: an HTTP/2 peer that legitimately defers its
response head past 30 seconds — long polling, or a query it computes before
flushing headers — is refused at the stream level, even though the connection
would have waited forever. BB_CLIENT_HEAD_TIMEOUT=0 is the opt-out, and it
disables the HTTP/1.1 arm along with it. HTTP/1.1 has always refused that shape
at this number; the HTTP/2 side was brought into line because the peer, not
you, chooses which client Client hands back, and a limit answerable on one
path only is a limit you cannot configure.
What the defaults bound¶
The reasoning for every number lives in Environment variables and is not repeated here. What that table does not show at a glance is the shape — which question each knob answers:
| question | knobs |
|---|---|
| how big may one thing be | BB_CLIENT_HEAD_MAX_LINE, BB_CLIENT_WS_MAX_FRAME_PAYLOAD, BB_CLIENT_H2_MAX_FRAME_SIZE |
| how big may the total be | BB_CLIENT_HEAD_MAX_TOTAL, BB_CLIENT_H2_MAX_HEADER_LIST_SIZE, BB_CLIENT_WS_MAX_MESSAGE_SIZE, BB_CLIENT_RAW_QUEUE_DEPTH, BB_CLIENT_BODY_MAX_TOTAL (off) |
| how long may it take | BB_CLIENT_HEAD_TIMEOUT, BB_CLIENT_BODY_TIMEOUT, BB_CLIENT_WRITE_TIMEOUT, connect_timeout= (an argument, not a knob) |
| how many may there be | BB_CLIENT_MAX_INTERIM_RESPONSES |
| how slowly may it arrive | BB_CLIENT_MIN_BODY_RATE (off), paced by BB_CLIENT_MIN_BODY_RATE_GRACE |
| not a bound at all | BB_CLIENT_H2_ENABLE_PUSH — a conformance switch |
Reading it that way makes the gaps visible, and the gaps are the point.
What is not bounded¶
A client is not a server, and the defaults say so. A server bounds what strangers may push into your process; a client bounds what you went and asked for. Two axes are therefore off out of the box, and you should turn them on deliberately rather than assume they are on:
- The response body has no total.
BB_CLIENT_BODY_MAX_TOTALdefaults to0. On HTTP/1.1 the escape isstream(), which never accumulates. On HTTP/2 there is nostream(), so with the default every h2 response body is buffered whole with no total watching it. If you know what your peer should be returning, set it — and size it against twice the value, which is what reaching the cap costs in peak memory. - A trickling peer is not refused.
BB_CLIENT_MIN_BODY_RATEdefaults to0. The body deadline abandons a peer that stops; one that sends a byte before every deadline satisfies it forever.
Two more are unbounded by design rather than by default:
- An upload has no total time.
BB_CLIENT_WRITE_TIMEOUTbounds one socket drain or one flow-control credit wait. A peer granting one byte at a time can keep a large upload going indefinitely. - A quiet connection is never closed by the client. Only a stream with a request outstanding is on a clock.
Every bound that fires logs one WARNING on the blackbull.caps logger naming
itself — see Logging. For a client that
is most of the value: it turns "the request failed" into "this limit, this
number, this peer".
Driving a misbehaving peer¶
execute_scenario and the scenario_mode=True flag are for putting a
deliberately broken client on the wire — slowloris, a lied-about frame length,
a rapid-reset burst. scenario_mode is the one place the two clients genuinely
diverge, and Fault injection
explains why and shows the vocabulary. Note that a scenario never raises; the
outcome lands on ScenarioResult.