Aiohttp: Websocket-Lese-Nachrichtenschleife löst CanceledError auf niedriger Ebene aus, wenn die Verbindung unerwartet geschlossen wird

Erstellt am 6. Juli 2017  ·  4Kommentare  ·  Quelle: aio-libs/aiohttp

Tatsächliches Verhalten

Beim Lesen der Nachrichtenschleife async for msg in ws: wird concurrent.futures._base.CancelledError niedriger Ebene

Erwartetes Verhalten

Es wird erwartet, dass eine Nachricht vom Typ aiohtto.http_websocket.WSMsgType.ERROR abgerufen oder die Schleife stillschweigend gestoppt wird, oder mindestens aiohtto.http_websocket.WebSocketError .

Schritte zum Reproduzieren

Führen Sie die folgenden zwei Skripte server.py und client.py und stoppen Sie dann client.py durch Ctrl+C .

server.py

import logging

from aiohttp import web


logger = logging.getLogger(__name__)


async def index(request):
    ws = web.WebSocketResponse()
    request.app['websockets'].add(ws)

    try:
        await ws.prepare(request)
        logger.debug('Connected')
        async for msg in ws:
            logger.info('Received: %r', msg.data)
    except Exception:
        logger.exception('Error')
    logger.debug('Disconnected')

    request.app['websockets'].discard(ws)
    return ws


async def on_shutdown(app):
    for ws in app['websockets']:
        await ws.close()
    app['websockets'].clear()


def main():
    logging.basicConfig(level=logging.DEBUG)

    app = web.Application()
    app['websockets'] = set()
    app.router.add_get('/', index)
    app.on_shutdown.append(on_shutdown)

    web.run_app(app, host='127.0.0.1', port=9000)


if __name__ == '__main__':
    main()

client.py

import asyncio

import aiohttp


async def communicate(loop):
    async with aiohttp.ClientSession(loop=loop) as session:
        async with session.ws_connect('http://127.0.0.1:9000') as ws:
            while True:
                await ws.send_str('Hello')
                await asyncio.sleep(1, loop=loop)


def main():
    loop = asyncio.get_event_loop()
    loop.run_until_complete(communicate(loop))


if __name__ == '__main__':
    main()

Protokollausgabe von server.py

$ python server.py 
DEBUG:asyncio:Using selector: EpollSelector
======== Running on http://127.0.0.1:9000 ========
(Press CTRL+C to quit)
DEBUG:__main__:Connected
INFO:__main__:Received: 'Hello'
INFO:__main__:Received: 'Hello'
INFO:__main__:Received: 'Hello'
INFO:__main__:Received: 'Hello'
INFO:__main__:Received: 'Hello'
INFO:__main__:Received: 'Hello'
INFO:__main__:Received: 'Hello'
INFO:__main__:Received: 'Hello'
INFO:__main__:Received: 'Hello'
ERROR:__main__:Error
Traceback (most recent call last):
  File "server.py", line 16, in index
    async for msg in ws:
  File "/home/vagrant/project/workspace/pyenv_dev/lib64/python3.5/site-packages/aiohttp/web_ws.py", line 343, in __anext__
    msg = yield from self.receive()
  File "/home/vagrant/project/workspace/pyenv_dev/lib64/python3.5/site-packages/aiohttp/web_ws.py", line 273, in receive
    msg = yield from self._reader.read()
  File "/home/vagrant/project/workspace/pyenv_dev/lib64/python3.5/site-packages/aiohttp/streams.py", line 627, in read
    return (yield from super().read())
  File "/home/vagrant/project/workspace/pyenv_dev/lib64/python3.5/site-packages/aiohttp/streams.py", line 509, in read
    yield from self._waiter
  File "/usr/lib64/python3.5/asyncio/futures.py", line 380, in __iter__
    yield self  # This tells Task to wait for completion.
  File "/usr/lib64/python3.5/asyncio/tasks.py", line 304, in _wakeup
    future.result()
  File "/usr/lib64/python3.5/asyncio/futures.py", line 285, in result
    raise CancelledError
concurrent.futures._base.CancelledError
DEBUG:__main__:Disconnected
INFO:aiohttp.access:- - - [06/Jul/2017:11:41:25 +0000] "GET / HTTP/1.1" 101 0 "-" "Python/3.5 aiohttp/2.2.3"

Ihre Umgebung

Betriebssystem: CentOS Linux 7
Linux-Kernel: 3.10.0-514.16.1.el7.x86_64
Python: 3.5.3
aiohttp: 2.2.3

Hilfreichster Kommentar

Vielleicht ist es besser, eine separate ConnectionClosed-Ausnahme einzuführen, genau wie in der Websockets- Bibliothek?

Alle 4 Kommentare

Technisch erstellt aiohttp eine Aufgabe pro Client-Anfrage.
Beim Trennen des Clients stoppt das System die Task so schnell wie möglich.
Die einzige Möglichkeit, dies zu tun, ist das Abbrechen der Aufgabe (angenommen, der Web-Handler wartet auf eine Antwort von der DB oder einem anderen Dienst, wir möchten ihn auch abbrechen, ohne auf eine explizite Operation über die Verbindung zum Websocket-Client zu warten).

Task.cancel() wird durch das Senden von asyncio.CancelledError Ausnahme ausgeführt, die Ausnahmeklasse wird vom Standard Exception . Dies ist ein asyncio-Verhalten, nichts Spezifisches für aiohttp selbst.

Das einzige, was ich vorschlagen könnte, ist, CancelledError explizit in Ihrem Handler zu fangen:

try:
    ...
except asyncio.CancelledError:
    pass
except Exception as exc:
    log(exc)

Oder Sie könnten einfach nicht so breite Texte wie Exception .

Ich sehe zwei Möglichkeiten:

  • nichts ändern, CancelledError ist in der asynchronen Welt normal.
  • CancelledError abfangen und closed Nachricht zurückgeben. Ich denke, dies ist die bessere Lösung für Webocket-Handler.

Vielleicht ist es besser, eine separate ConnectionClosed-Ausnahme einzuführen, genau wie in der Websockets- Bibliothek?

Technisch erstellt aiohttp eine Aufgabe pro Client-Anfrage.
Beim Trennen des Clients stoppt das System die Task so schnell wie möglich.
Die einzige Möglichkeit, dies zu tun, ist das Abbrechen der Aufgabe (angenommen, der Web-Handler wartet auf eine Antwort von der DB oder einem anderen Dienst, wir möchten ihn auch abbrechen, ohne auf eine explizite Operation über die Verbindung zum Websocket-Client zu warten).

Ich glaube, ich wurde gerade von diesem Verhalten gebissen. Ich hatte einen Code wie diesen:

async def handler(request):
    ws = web.WebSocketResponse()
    await ws.prepare(request)

    async with contextlib.AsyncExitStack() as stack:
        # acquire_resource_X are async context managers
        await stack.enter_async_context(acquire_resource_1())
        await stack.enter_async_context(acquire_resource_2())
        await stack.enter_async_context(acquire_resource_3())

        async for msg in ws:
            # do stuff

    await ws.close()

    return ws

Nachdem ich es in Produktion genommen hatte, stellte ich fest, dass der aufregende Teil von acquire_resource_3() stillschweigend übersprungen würde. Weitere Protokollierung ergab, dass ein CancelledError innerhalb von acquire_resource_3 . Folgendes ist meiner Meinung nach passiert:

  • Client schließt den WebSocket
  • async for msg in ws Schleife wird beendet, AsyncExitStack beginnt sich abzuwickeln, der Ausgangsteil von acquire_resource_3 beginnt mit der Ausführung, trifft auf await
  • aiohttp bricht die Handler-Aufgabe ab
  • CancelledError wird innerhalb von handler zum aktuellen await erhöht, der innerhalb von acquire_resource_3 , daher wird der verbleibende Teil von acquire_resource_3 übersprungen
  • Der beendende Teil von acquire_resource_2 und acquire_resource_1 immer noch normal ausgeführt, da sie aus ihrer Sicht einfach einen asynchronen Kontext bei einer Ausnahme verlassen

Dies ist ein wirklich seltsames Problem, insbesondere weil es die Erwartung bricht, dass der Exit-Teil eines Kontextmanagers immer ausgeführt wird. Ich musste im Grunde alle asynchronen Kontexte wie folgt vor dem Abbruch schützen:

async def handler(request):
    ws = web.WebSocketResponse()
    await ws.prepare(request)

    await asyncio.shield(asyncio.ensure_future(actually_do_stuff(ws)))

    return ws

async def actually_do_stuff(ws):
    async with contextlib.AsyncExitStack() as stack:
        # acquire_resource_X are async context managers
        await stack.enter_async_context(acquire_resource_1())
        await stack.enter_async_context(acquire_resource_2())
        await stack.enter_async_context(acquire_resource_3())

        async for msg in ws:
            # do stuff

    await ws.close()

Gibt es einen besseren Weg, dies zu tun?

War diese Seite hilfreich?
0 / 5 - 0 Bewertungen