Skip to content

blackbull.server.recipient

blackbull.server.recipient

AbstractReader

Bases: ABC

Protocol-agnostic async byte-source.

Mirrors AbstractWriter on the receive side. Implementations wrap a concrete transport so that BaseRecipient subclasses stay runtime-agnostic.

at_eof()

Return True once the peer has closed and the buffer is drained.

Default False (callers that need EOF detection — e.g. a long-lived raw-protocol read loop — should use a reader that overrides this).

readexactly(n) async

Read exactly n bytes. Default: accumulate via :meth:read. Concrete transport readers override this.

readuntil(sep) async

Read until sep is seen (inclusive). Default: byte-wise via :meth:read. Concrete transport readers override this with the stream's native, buffered implementation.

AsyncioReader

Bases: AbstractReader

Adapts an asyncio-compatible stream to AbstractReader.

Accepts any object exposing read(), readuntil(), and readexactly() — the asyncio StreamReader API — so that test doubles such as MagicMock can be injected without ceremony.

BaseRecipient

Bases: ABC

Abstract base for ASGI-event receive callables.

__call__ returns an ASGI event dict appropriate to the protocol: - HTTP: {'type': 'http.request', 'body': ..., 'more_body': False} - WebSocket: {'type': 'websocket.connect'}, {'type': 'websocket.receive', ...}, or {'type': 'websocket.disconnect', ...}

The actual byte transport is hidden behind AbstractReader so the recipient logic is decoupled from asyncio internals.

FragmentAssembler

Accumulates RFC 6455 fragmented frames and signals message completion.

Feed each data/continuation frame via feed(). Returns (message_opcode, full_payload) when the final FIN=1 continuation arrives; returns None while still accumulating.

Raises ProtocolError on violations: - CONTINUATION frame with no fragmentation in progress (§5.4) - New TEXT/BINARY opener while a fragmented message is open (§5.4)

feed(opcode, payload, fin, rsv1=False)

Feed one frame; return (message_opcode, full_payload, compressed) on completion, else None.

HTTP1Recipient

Bases: BaseRecipient

Reads an HTTP/1.1 request body and emits a single http.request event.

Body bytes are read lazily on the first __call__ using the Content-Length or Transfer-Encoding header from scope. Subsequent calls return {'type': 'http.disconnect'}.

drain(max_bytes) async

Discard any unread request body so the next pipelined request parses cleanly. Returns True if fully drained (or the peer disconnected), False if the unread body exceeded max_bytes — the caller should then close the connection rather than keep it alive.

needs_drain()

True if a declared request body may still be buffered unread.

A handler that ignores receive (e.g. a 404/405 response to a POST) leaves the body bytes in the reader; the next keep-alive request would then parse them as its request line. The actor consults this after dispatch to decide whether to drain. A body-less request (GET, no Content-Length, not chunked) never needs draining.

HTTP2Recipient

Bases: BaseRecipient

Delivers HTTP/2 DATA frames as ASGI http.request events.

The server loop feeds frames via put_DATAFrame() (non-blocking). The ASGI app calls __call__() which suspends until an event is available, hiding the concurrency from both sides.

For GET-style requests (END_STREAM on HEADERS, no DATA frames), the caller invokes :meth:mark_end_of_stream_on_headers instead of pre-queuing an empty http.request event. The Queue is then never allocated — the empty event is synthesized lazily in :meth:__call__ only if the handler reads it.

Consume-based inbound flow control (the Sprint 62 deferral, proposals/consume-based-inbound-flow-control.md): when constructed with a credit_callback, WINDOW_UPDATE credit for a DATA frame is replayed through the callback when the app pops the event — not when the frame is enqueued. A stalled handler then stops crediting, the peer's window closes, and the peer back-pressures instead of overflowing a frame-count queue into RST_STREAM(ENHANCE_YOUR_CALM). In this mode the queue is bounded by credit_budget bytes (the advertised inbound window — a conformant peer cannot exceed it) plus a generous frame-count abuse cap; put_DATAFrame returning False therefore means the peer overran the closed window or dribbled degenerate frames, and the RST is a true abuse backstop. Without a callback the historical bounded-queue, credit-at-enqueue behaviour is preserved (push streams, direct test use).

credits_on_consume property

True when WINDOW_UPDATE credit is replayed at consume-time.

The actor must then NOT credit at enqueue — the recipient's credit_callback owns the replay.

mark_end_of_stream_on_headers()

Mark this stream as ended on HEADERS (no body to deliver).

Replaces put_event({type: http.request, body: b'', more_body: False}) with a flag — saves one asyncio.Queue allocation per body-less request.

put_DATAFrame(frame)

Enqueue a DATA frame event. Returns False when the frame must be refused (the caller answers RST_STREAM): queue full in legacy mode; inbound-window overrun or a tiny-frame flood in consume-crediting mode.

put_disconnect()

Unblock a waiting call() with an http.disconnect event.

Skipped when end-of-stream-on-headers has been delivered and no queue was ever created — no consumer can be waiting.

put_event(event)

Enqueue a pre-built event dict. Returns False if the queue is full.

take_uncredited()

Return and clear the un-consumed credit balance.

Bytes enqueued but never popped by the app (a handler that finished — or was RST — without draining its body). The actor replays this to the CONNECTION window when the stream is released, otherwise the shared window leaks shut for every later stream; the stream-level window is moot once the stream closes (RFC 9113 §5.1).

IncompleteReadError

Bases: EOFError

Raised by AbstractReader when the peer closes the connection mid-read.

Mirrors asyncio.IncompleteReadError but is not tied to asyncio, so handlers that depend on AbstractReader remain runtime-agnostic.

PrefixReader

Bases: AbstractReader

An :class:AbstractReader that replays an already-read prefix.

Connection detection peeks the first bytes of a stream to decide which protocol owns it; wrapping the underlying reader in a PrefixReader hands the still-complete stream to the protocol that claims it — the peeked bytes are served back first, then reads fall through to the underlying reader.

Used by the decouple-connection-detection refactor so the dispatcher no longer consumes protocol-specific bytes on the connection's behalf. The fast native readuntil / readexactly of the underlying reader are used once the prefix is drained, including the seam case where the separator straddles the prefix/underlying boundary.

ProtocolError

Bases: Exception

Raised when a WebSocket protocol violation is detected (RFC 6455).

close_code is the RFC 6455 §7.4 status code that should appear in the CLOSE frame sent to the peer. Defaults to 1002 (PROTOCOL_ERROR); UTF-8 violations use 1007.

RecipientFactory

Creates the appropriate BaseRecipient for the given protocol.

All methods that need a reader accept a raw asyncio-compatible stream reader and wrap it in AsyncioReader internally.

WebSocketRecipient

Bases: BaseRecipient

Reads WebSocket frames and emits ASGI websocket.* events.

First call returns {'type': 'websocket.connect'}. Subsequent calls read the next frame from the transport: - Text frame → {'type': 'websocket.receive', 'text': ..., 'bytes': None} - Binary frame → {'type': 'websocket.receive', 'text': None, 'bytes': ...} - Close frame → {'type': 'websocket.disconnect', 'code': 1000} - Ping frame → sends Pong immediately, then reads the next frame - Pong frame → silently dropped, reads the next frame

Ping/pong handling requires write access to the transport, so the raw writer is stored alongside the reader.

shutdown() async

Cancel and await the background read-loop task.

Sprint 72 (audit 1.20c) — client sessions call this from close() so no reader task outlives the session (a leaked task warns at event-loop shutdown and keeps reading a dead transport). Idempotent, and safe to call before the first __call__ ever started the loop.