Streaming, SSE and WebSockets#

Streams#

ResponseStream takes an async generator and sends each chunk as it is produced. Return it from a handler; nothing gets buffered:

from muffin import ResponseStream

@app.route("/export")
async def export(request):
    async def rows():
        async for row in fetch_rows():
            yield f"{row.id}\t{row.name}\n"

    return ResponseStream(rows(), headers={
        "Content-Type": "text/tab-separated-values",
        "Content-Disposition": 'attachment; filename="export.tsv"',
    })

Cleanup belongs in finally inside the generator, because the client can go away in the middle of a stream:

async def rows():
    cursor = await db.cursor()
    try:
        async for row in cursor:
            yield format_row(row)
    finally:
        await cursor.close()

Server-Sent Events#

ResponseSSE is a stream with the text/event-stream content type and the framing SSE requires. Yield strings or dicts; dicts are rendered as field: value lines, and every event is terminated by a blank line:

from muffin import ResponseSSE

@app.route("/events")
async def events(request):
    async def ticks():
        while True:
            await sleep(1)
            yield {"event": "tick", "data": now()}   # event: tick\ndata: ...\n\n

    return ResponseSSE(ticks())

The response sets Cache-Control: no-cache; send a blank line with a comment (": keep-alive") if proxies need it.

Files#

ResponseFile streams a file from disk and sets Content-Disposition when filename is given:

from muffin import ResponseFile

@app.route("/download/{name}")
async def download(request):
    name = request.path_params["name"]
    return ResponseFile(f"uploads/{name}", filename=name)

Under asyncio, file streaming needs the optional aiofile package: pip install aiofile.

WebSockets#

A websocket handler owns the connection for as long as it is open:

from muffin import ASGIConnectionClosedError, ResponseWebSocket

@app.route("/ws")
async def ws(request):
    async with ResponseWebSocket(request) as socket:
        try:
            while True:
                message = await socket.receive()
                await socket.send(f"echo: {message}")
        except ASGIConnectionClosedError:
            return   # normal disconnect, nothing to log

receive() raises ASGIConnectionClosedError when the client closes the connection or the server shuts down. Catch it where the handler loop lives; letting it escape produces a logged error for a case that is not an error. HTTP and websocket routes live in the same application; the ASGI server decides which protocol a connection uses.

Testing streams#

The test client reads the whole stream, so streaming handlers are tested like regular ones:

async def test_export(client):
    response = await client.get("/export")
    assert response.status_code == 200
    assert await response.text() == "1\tone\n"

For websockets use client.websocket(); see Testing.