Streaming responses¶
BlackBull streams responses by yielding chunks from an async iterator. The framework handles transport framing (HTTP/1.1 chunked encoding or HTTP/2 DATA frames) and the backpressure model. This page covers both halves: the surface for writing a streaming handler, and the model behind the curtain so you know what your code is actually doing.
The simplified shape: async generator¶
Return an async generator from a simplified handler and BlackBull
wraps it in a StreamingResponse for you:
import asyncio
from blackbull import BlackBull
app = BlackBull()
@app.route(path='/feed')
async def feed():
for i in range(10):
yield f'line {i}\n'.encode()
await asyncio.sleep(0.1)
Each yield becomes one body chunk. bytes is preferred; str
is auto-encoded as UTF-8 for convenience. No await send(...)
required, no Response object required.
The handler is still bounded by the normal request-timeout knobs —
BB_REQUEST_TIMEOUT and BB_WRITE_TIMEOUT apply to streaming
handlers the same way they apply to any other.
StreamingResponse¶
For finer control, construct a StreamingResponse explicitly:
from blackbull import StreamingResponse
@app.route(path='/feed')
async def feed():
async def lines():
for i in range(10):
yield f'line {i}\n'
return StreamingResponse(lines(), media_type='text/plain')
Constructor arguments:
| Argument | Default | Meaning |
|---|---|---|
content |
— | Async iterator producing bytes or str chunks. |
status |
200 |
HTTP status code on the response start. |
headers |
[] |
Extra response headers as [(bytes, bytes), ...]. |
media_type |
'text/plain' |
Content-Type header (overridable via headers). |
Pass it to await send(...) from a full-ASGI handler, or just
return it from a simplified handler.
Server-Sent Events: EventSourceResponse¶
For browser-side EventSource clients, use the SSE convenience
class. It formats each yielded item per the WHATWG SSE grammar,
forces Content-Type: text/event-stream, and sets
Cache-Control: no-cache:
from blackbull import EventSourceResponse
@app.route(path='/sse')
async def sse():
async def events():
yield {'event': 'token', 'data': 'hello'}
yield {'event': 'token', 'data': 'world'}
yield {'event': 'done', 'data': ''}
return EventSourceResponse(events())
Yielded items accept three shapes:
| Yield | Wire emission |
|---|---|
str |
data: <text>\n\n |
bytes |
data: <text-utf8>\n\n |
Mapping |
One field line per recognised key, blank-line terminator |
Recognised mapping keys are data, event, id, and retry.
data may be a string or UTF-8 bytes with embedded CR, LF, or CRLF. Each
logical line emits its own data: field and the browser rejoins those lines
with \n; empty, consecutive, and trailing lines are preserved. A byte value
that is not valid UTF-8 raises UnicodeDecodeError. Other data values are
JSON-serialised before the wire.
event and id are converted with str(). Because each represents exactly
one SSE field, CR or LF in either raises ValueError; NUL is also rejected in
id, where the WHATWG reader would otherwise ignore the update. retry is
converted with int() and emitted as ASCII milliseconds. Unknown keys are
ignored. Validation happens per yielded item, so it does not buffer the async
stream.
Override the default headers by passing your own headers=[...]
— a caller-supplied cache-control or content-type takes
precedence.
The backpressure model¶
Streaming handlers fire one await send({'type': 'http.response.body', ...})
per chunk. Both HTTP/1.1 and HTTP/2 paths apply transport-level
backpressure inside that await, so a slow client never causes
unbounded buffer growth in BlackBull.
HTTP/1.1¶
AsyncioWriter.write() calls asyncio.StreamWriter.drain() on
every chunk
(blackbull/server/sender.py).
drain() blocks while the kernel send buffer is full of bytes the
peer hasn't acknowledged, so the handler naturally stalls until
the peer pulls bytes off the socket. BB_WRITE_TIMEOUT (default
30 s) bounds how long a single drain can wait — if a peer reads
1 byte/sec, the connection is closed before the handler hangs
indefinitely.
HTTP/2¶
HTTP/2 writes require both stream and connection flow-control credit. The next generator yield waits for send to finish, so a closed window applies backpressure. BB_WRITE_TIMEOUT bounds an individual socket/credit wait; BB_REQUEST_TIMEOUT can impose a total handler budget, including intentional long-lived streams. These waits do not need application-side polling.
When SSE, when WebSocket, when chunked HTTP¶
| Pattern | Strengths | Weaknesses |
|---|---|---|
SSE (EventSourceResponse) |
Server → client only; auto-reconnect in browsers via EventSource; works through HTTP proxies; cleanly multiplexed over HTTP/2 streams. |
One-way only. Text-only payloads on the wire (binary needs base64). |
| WebSocket | Bidirectional; binary-native; lowest per-message framing overhead after the upgrade. | Most HTTP middleboxes treat WS as opaque; harder to debug; framework state on both sides. |
Chunked HTTP (StreamingResponse) |
One-way, request-scoped; uses normal HTTP semantics; trivially cacheable / proxied. | No event names, no client reconnect. Use when you just want a long-running download, not a live event stream. |
Default pick: SSE when the client is a browser tab consuming
incremental updates, WebSocket when the client also pushes,
and plain StreamingResponse for non-browser HTTP clients
(LLM token streams over httpx.stream, log tail downloads, etc.).
Caveats¶
- Compression interaction: Compression handles complete bodies. On a streamed chunk, it releases any held response head and passes subsequent chunks through uncompressed; it does not gather the whole stream.
Content-Length: streaming responses do not setcontent-length; the transport advertises chunked encoding (HTTP/1.1) or stream framing (HTTP/2). Clients that require a known length should buffer client-side.- Cancellation: when the client disconnects, the handler's
next
await send(...)raises and the generator'sfinallyblocks run. Use atry / finallyaround any resource that outlives a single yield (DB cursors, subscriptions, …).