Skip to content

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:

  1. derive the ASGI scope via :meth:as_scope (the one native→ASGI point);
  2. when force_asgi (the §4.3 BB_FORCE_ASGI_SCOPE dual-path lane), round-trip through :meth:from_scope so both the derived scope and the Connection the consumers read are rebuilt from scratch on every request, keeping the compat conversion from bitrotting. _asterisk_form is Connection-only (not in the scope), so carry it across the rebuild;
  3. restore scope['headers'] to the rich :class:Headers object (as_scope emits the ASGI list[tuple] form; internal .get() callers want the object);
  4. re-expose the H/1.1 _asterisk_form OPTIONS marker on the envelope;
  5. 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_scopefrom_scopeas_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.