quart icon indicating copy to clipboard operation
quart copied to clipboard

Streaming response API is not compatible with async context managers

Open lordmauve opened this issue 2 years ago • 2 comments

The current streaming response API requires returning an async generator. This is really awkward to use given that so many async classes are not RAII - initialised with __init__ and finalised with __del__ - but are async context managers, initialised with __aenter__ and finalised with __aexit__.

For example, to use httpx's streaming responses in a quart streaming response, I came up with this abomination:

client = httpx.AsyncClient()

@app.route(...)
async def proxy_response():
    resp = await client.stream('GET', some_url).__aenter__()
    if resp.status_code == 404:
        return "not found", 404
    async def iter_response():
        try:
            async for chunk in resp.aiter_bytes():
                yield chunk
        finally:
            await resp.__aexit__(None, None, None)
    return iter_response(), 200

I don't think this is necessarily right because if the iter_response generator is discarded because the remote client hangs up, then maybe? GeneratorExit is raised but I doubt that there is a task that would actually run the await in the finally block.

Plain ASGI doesn't have this problem: you can just wrap async context managers around a bunch of

await send({'type': 'http.response.body', 'body': data})

so

async def app(scope, send, receive):
    async with client.stream('GET', some_url) as resp:
        if resp.status_code == 404:
            await send({'type': 'http.response.start', 'status': 404})
            await send({'type': 'http.response.body', 'body': "Not found"})
            return

        await send({'type': 'http.response.start', 'status': 200})
        async for chunk in resp.aiter_bytes():
            await send({'type': 'http.response.body', 'body': chunk})

I would propose an API in Quart that allows this, e.g.

@app.route(...)
async def proxy_response():
     async with client.stream('GET', some_url) as resp:
         if resp.status_code == 404:
             return "not found", 404
         async with quart.streaming_response(200, headers={'Content-Length': resp.headers['Content-Length']) as send:
             async for chunk in resp.aiter_bytes():
                 await send(chunk)

lordmauve avatar Apr 06 '23 09:04 lordmauve

I think related to #218 in that that issue arises because of the awkwardness of returning generators.

lordmauve avatar Apr 06 '23 09:04 lordmauve