Aiohttp: O loop da mensagem de leitura do Websocket aumenta CanceledError de baixo nível quando a conexão é fechada inesperadamente

Criado em 6 jul. 2017  ·  4Comentários  ·  Fonte: aio-libs/aiohttp

Comportamento real

A leitura do loop de mensagem async for msg in ws: levanta concurrent.futures._base.CancelledError baixo nível quando a conexão é fechada inesperadamente.

Comportamento esperado

Esperado para obter a mensagem com o tipo aiohtto.http_websocket.WSMsgType.ERROR , ou interromper o loop silenciosamente, ou pelo menos aiohtto.http_websocket.WebSocketError .

Passos para reproduzir

Execute os dois scripts a seguir server.py e client.py , então pare client.py por 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()

Log de saída de 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"

Seu ambiente

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

Comentários muito úteis

Talvez seja melhor introduzir uma exceção ConnectionClosed separada, da mesma forma que foi feito na biblioteca de websockets ?

Todos 4 comentários

Tecnicamente, aiohttp cria uma tarefa por solicitação do cliente.
Na desconexão do cliente, o sistema interrompe a tarefa o mais rápido possível.
A única maneira de fazer isso é cancelando a tarefa (vamos supor que o manipulador da web esteja aguardando a resposta do banco de dados ou outro serviço, queremos cancelá-lo também sem esperar por uma operação explícita na conexão com o cliente do websocket).

Task.cancel() é feito enviando asyncio.CancelledError exception, a classe de exceção é derivada do padrão Exception . Este é um comportamento assíncio, nada específico para o próprio aiohttp.

A única coisa que eu poderia sugerir é capturar CancelledError em seu manipulador explicitamente:

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

Ou você pode simplesmente não pegar um tipo tão amplo como Exception .

Vejo duas opções:

  • não mude nada, CancelledError é normal no mundo assíncrono.
  • pegar CancelledError e retornar closed mensagem. Eu acho que essa é a melhor solução para o manipulador de webocket.

Talvez seja melhor introduzir uma exceção ConnectionClosed separada, da mesma forma que foi feito na biblioteca de websockets ?

Tecnicamente, aiohttp cria uma tarefa por solicitação do cliente.
Na desconexão do cliente, o sistema interrompe a tarefa o mais rápido possível.
A única maneira de fazer isso é cancelando a tarefa (vamos supor que o manipulador da web esteja aguardando a resposta do banco de dados ou outro serviço, queremos cancelá-lo também sem esperar por uma operação explícita na conexão com o cliente do websocket).

Acho que acabei de ser mordido por esse comportamento. Eu tinha um código como este:

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

Depois de colocá-lo em produção, descobri que a parte final de acquire_resource_3() seria ignorada silenciosamente. Mais registros revelaram que CancelledError estava sendo gerado dentro de acquire_resource_3 . Aqui está o que acho que aconteceu:

  • O cliente fecha o WebSocket
  • async for msg in ws loop sai, o AsyncExitStack começa a se desenrolar, a parte de saída de acquire_resource_3 começa a executar, atinge um await
  • aiohttp cancela a tarefa do manipulador
  • CancelledError é gerado dentro de handler no atual await , que está dentro de acquire_resource_3 , portanto, a parte restante de acquire_resource_3 é ignorada
  • A parte de saída de acquire_resource_2 e acquire_resource_1 ainda é executada normalmente, pois da perspectiva deles eles estão simplesmente saindo de um contexto assíncrono em uma exceção

Este é um problema realmente estranho, principalmente porque quebra a expectativa de que a parte existente de um gerenciador de contexto sempre será executada. Tive que basicamente proteger todos os contextos assíncronos do cancelamento, como este:

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()

Existe uma maneira melhor de fazer isso?

Esta página foi útil?
0 / 5 - 0 avaliações