sanic icon indicating copy to clipboard operation
sanic copied to clipboard

`Async for` can be used to iterate over a websocket's incoming messages

Open bradlangel opened this issue 2 years ago • 11 comments

Is your feature request related to a problem? Please describe.

When creating a websocket server I'd like to use async for to iterate over the incoming messages from a connection. This is a feature that the websockets lib uses. Currently, if you try to use async for you get the following error:

TypeError: 'async for' requires an object with __aiter__ method, got WebsocketImplProtocol

Describe the solution you'd like

Ideally, I could run something like the following on a Sanic websocket route:

@app.websocket("/")
async def feed(request, ws):
    async for msg in ws:
        print(f'received: {msg.data}')
        await ws.send(msg.data)

Additional context This was originally discussed on the sanic-support channel on the discord server

bradlangel avatar May 23 '22 21:05 bradlangel

I will take a look and see what can be done, if no one has started working on it. Thanks for creating the issue!

ChihweiLHBird avatar Jun 26 '22 16:06 ChihweiLHBird

This feature was available in earlier version of sanic years ago, maybe earlier version uses websockets internally? The async for look very pythonic and I like it too. here's a simple workaround

class async_for_ws:
    def __init__(self, ws):
        self.ws = ws    
    def __aiter__(self):
        return self        
    async def __anext__(self):
        return await self.ws.recv()

async for msg in async_for_ws(ws):
    ....

hyansuper avatar Jun 27 '22 15:06 hyansuper

You can do it without __anext__ or any other extra boilerplate by simply adding this function to the websocket class:

async def __aiter__(self):
    while True:
        yield await self.recv()

As a user workaround, I suppose one can monkey patch that on Sanic's websocket, but hopefully @ChihweiLHBird gets the patch done and merged in time for the upcoming Sanic 22.6 release.

Tronic avatar Jun 28 '22 05:06 Tronic

You can do it without __anext__ or any other extra boilerplate by simply adding this function to the websocket class:

async def __aiter__(self):
    while True:
        yield await self.recv()

As a user workaround, I suppose one can monkey patch that on Sanic's websocket, but hopefully @ChihweiLHBird gets the patch done and merged in time for the upcoming Sanic 22.6 release.

I think __anext__ is still worth because it makes the object supports anext. Consider this case:

async def test_anext():
    while True:
        await anext(ws)

ChihweiLHBird avatar Jun 30 '22 06:06 ChihweiLHBird

My question is, should we make the whole ws (WebsocketImplProtocol) object async iterable? Or just make another recv-like method to return an async generator?

async def recv_generator(self):
    while True:
        yield await self.recv()

Any opinion?

ChihweiLHBird avatar Jun 30 '22 06:06 ChihweiLHBird

I’d like it to have same behavior as the websockets lib

hyansuper avatar Jun 30 '22 09:06 hyansuper

I’d like it to have same behavior as the websockets lib

:point_up:

ahopkins avatar Jun 30 '22 11:06 ahopkins

My question is, should we make the whole ws (WebsocketImplProtocol) object async iterable? Or just make another recv-like method to return an async generator?

The whole purpose of __aiter__ is to produce an iterable object. Making that function async lets Python do that built-in without needing us to implement an object for it, or supporting __anext__ on the websocket object. Similarly as you cannot next(a_list) without first doing iter(a_list) and then next() on what that returns. async for automatically calls first __aiter__ and then __anext__ behind the scenes.

Tronic avatar Jul 01 '22 10:07 Tronic

Because this is Sanic, I ran a benchmark. The built-in async iterator is some nanoseconds faster per call but while barely measurable, the difference is utterly negligible to anything else that receiving from a WebSocket does. The benchmark code (written on ipython prompt and timed with %timeit) is a bit too long to post here.

Also the situation reverses if only a few messages are to be received within the loop, as the built-in iterator still needs to be constructed, while returning self in __aiter__ avoids this.

One thing to consider is what to do if the websocket is closed, should it exit the loop cleanly or just raise some exception? Probably the latter, which doesn't then need any extra work. The loop will then never exit normally.

Tronic avatar Jul 01 '22 17:07 Tronic

Because this is Sanic, I ran a benchmark. The built-in async iterator is some nanoseconds faster per call but while barely measurable, the difference is utterly negligible to anything else that receiving from a WebSocket does. The benchmark code (written on ipython prompt and timed with %timeit) is a bit too long to post here.

Also the situation reverses if only a few messages are to be received within the loop, as the built-in iterator still needs to be constructed, while returning self in __aiter__ avoids this.

One thing to consider is what to do if the websocket is closed, should it exit the loop cleanly or just raise some exception? Probably the latter, which doesn't then need any extra work. The loop will then never exit normally.

Okay, I agree with you. Benchmark matters and I think the scenario of large number of messages is more common.

ConnectionClosedOK exception will be raised if it closed normally. So, I think we can catch this and return while leaving other exceptions to raise up.

ChihweiLHBird avatar Jul 01 '22 18:07 ChihweiLHBird

Code clarity should probably take precedence since there really is no speed difference either way :)

Tronic avatar Jul 01 '22 18:07 Tronic