blackbull.connection¶
blackbull.connection
¶
The native Connection interface — BlackBull's single internal request
representation (Sprint 79, proposal native-connection-interface.md).
BlackBull is a multi-protocol server that owns both sides of the wire on its
self-hosted path; the ASGI scope dict is therefore an internal
data-format choice, not an interoperability contract — and a poor one
(untyped, string-keyed, carrying private _-prefixed keys). This module
makes a typed :class:Connection the internal model; the ASGI scope becomes
a derived view produced by :meth:Connection.as_scope and consumed by
:meth:Connection.from_scope, used only where external compatibility needs
it (uvicorn, httpx.ASGITransport/TestClient, third-party ASGI middleware).
The _CONNECTION_FIELDS registry is the single source of truth from which
both conversions are generated: adding a field to :class:Connection without
a registry entry is a test failure (proposal §4.2 / §9.5), which mechanically
prevents the two representations from drifting apart.
ClientDisconnected
¶
Bases: Exception
Raised when the client disconnects before the request body is complete.
ASGI signals a mid-body disconnect with an http.disconnect event that
carries no body/more_body keys. Treating it as end-of-message
would return a truncated upload as if it were whole, so
:func:read_body raises this instead — the handler must not process a
partial body as complete. The partial attribute holds whatever body
bytes had arrived before the disconnect.
Connection
dataclass
¶
One HTTP (or WebSocket) request — the single internal representation.
Built by the protocol actor, consumed by the router, dispatcher,
middleware, and handlers. The ASGI scope dict is a derived view
(:meth:as_scope). Request is a deprecated alias of this class.
cookies
property
¶
Cookies from the Cookie header, parsed once and cached.
path_params
property
writable
¶
Matched URL path params, set by the router (values are converter-
coerced, hence Any). The backing dict is created lazily on first
access so no-param routes allocate nothing.
subprotocols
property
¶
The client-offered WebSocket subprotocols (ASGI websocket scope's
subprotocols), parsed from the Sec-WebSocket-Protocol request
header. Empty on HTTP requests and on a WS handshake that offered none.
Derived — not stored — so it needs no ASGI round-trip and the header
stays the single source of truth.
as_scope()
¶
Generate a fresh ASGI 3.0 scope dict (the single native→ASGI point).
Mutations of the returned dict do not affect the Connection, except
that state and extensions are shared by reference so a
buffering middleware's writes reach the handler.
body()
async
¶
Return the complete request body, draining receive at most once.
Repeated calls (and :meth:json / :meth:text) return the cached
bytes. A mid-body disconnect raises :class:ClientDisconnected. Raises
:class:RuntimeError if :meth:stream already consumed the body — the
channel is a single drain, so there is nothing left to buffer.
from_scope(scope, receive=None)
classmethod
¶
Build a Connection from an external ASGI scope (the single ASGI→native point). Unknown keys are ignored; missing optional keys fall back to the field defaults.
json()
async
¶
Parse the cached body as JSON (None on empty/invalid input).
stream()
async
¶
Yield the request body one chunk at a time, draining receive once.
The streaming counterpart to :meth:body. Use it when the handler only
needs to process the body incrementally — count/hash/forward a large
upload — so the working set stays one chunk instead of the whole
payload::
total = 0
async for chunk in conn.stream():
total += len(chunk)
Mutually exclusive with :meth:body/:meth:json/:meth:text, which
buffer: the body is a single-drain stream, so mixing the two on one
request raises :class:RuntimeError rather than silently returning a
partial or empty body. A mid-body disconnect raises
:class:ClientDisconnected.
text(encoding='utf-8')
async
¶
Decode the cached body as text (errors='replace').
to_asgi_scope(*, force_asgi=False)
¶
Materialize the ASGI scope the dispatch pipeline consumes, with this
typed :class:Connection stashed on it for zero-reconversion reads.
The single canonical Connection → dispatch-ready scope bridge shared
by the H/1.1 run() and H/2 _conn_to_scope seams (Sprint 79) —
they used to hand-roll the same five steps:
- derive the ASGI scope via :meth:
as_scope(the one native→ASGI point); - when
force_asgi(the §4.3BB_FORCE_ASGI_SCOPEdual-path lane), round-trip through :meth:from_scopeso both the derived scope and the Connection the consumers read are rebuilt from scratch on every request, keeping the compat conversion from bitrotting._asterisk_formis Connection-only (not in the scope), so carry it across the rebuild; - restore
scope['headers']to the rich :class:Headersobject (as_scopeemits the ASGIlist[tuple]form; internal.get()callers want the object); - re-expose the H/1.1
_asterisk_formOPTIONS marker on the envelope; - stash the Connection under :data:
CONNECTION_STASH_KEY.
Protocol-specific augmentation (the websocket-only subprotocols key)
is layered on by the caller after this returns — it is not a
:class:Connection field (proposal §2.1).
Sprint 80 Tier-1: the default (force_asgi=False) native path builds
the scope by direct attribute access, placing the rich Headers
object straight in (no list(headers) that the old code computed via
the registry and then immediately discarded), and skips the per-field
function-call indirection of as_scope(). The force_asgi dual-path
conformance lane (§4.3) keeps the full as_scope → from_scope →
as_scope round-trip so the compat conversion is still exercised.
bind_receive_channel(target, receive)
¶
Bind the raw body-receive channel onto the request's Connection so
lazy conn.body() / request.body() drain the right stream once.
Called by the protocol actor with the unwrapped recipient — never with a
disconnect-detecting wrapper. Storing the wrapper would form a per-request
reference cycle: conn._receive → wrapper → (closure captures) conn.
The refcount of a cyclic group never reaches zero when the request's local
refs drop, so reclamation is deferred to the generational cyclic GC — whose
periodic pauses were the v0.60.0 tail-latency regression (see
.claude/planning/research/v0600-regression-investigation.md §6). The raw
recipient does not reference conn (HTTP/1.1 keeps only the path
string; HTTP/2 keeps none), so conn → recipient is an acyclic chain that
refcounting frees the instant the request ends.
Idempotent — binds only when unset, so the external-ASGI path (uvicorn /
httpx.ASGITransport), where :meth:Connection.from_scope already bound
the host's own receive channel, keeps that binding.
disconnected(target)
¶
True if the client disconnected mid-request. Accepts either a native
:class:Connection (the app(conn, …) path) or an ASGI scope dict
(the BB_FORCE_ASGI_SCOPE / external-server path).
mark_disconnected(target)
¶
Record a mid-request client disconnect on a :class:Connection or an
ASGI scope dict (idempotent — caller guards on :func:disconnected).
stashed_connection(target, receive)
¶
Return the typed :class:Connection for this request, plus whether it
was freshly built.
target is the threaded dispatch object — a :class:Connection on the
native path, or an ASGI scope dict on the external/compat lane.
The one ASGI-scope → Connection accessor shared by the dispatcher
(app._connection_of) and the router (router._conn_of), Sprint 79.
BlackBull's own protocol actors stash the Connection they parsed on the
scope envelope under :data:CONNECTION_STASH_KEY, so the self-hosted path
reads it back with no re-conversion. Under an external ASGI server
(uvicorn, httpx.ASGITransport) there is no stash, so build one via
:meth:Connection.from_scope — the single ASGI→native point — and stash it
so later accessors in the same request reuse the one object.
Returns (conn, built) where built is True only on the external
path (no prior stash). Callers layer their own post-processing on a freshly
built conn — the dispatcher links scope['state'] to conn.state; the
router seeds path_params from an input scope key — because those needs
differ by call site.