Skip to content

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 set content-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's finally blocks run. Use a try / finally around any resource that outlives a single yield (DB cursors, subscriptions, …).