aiohttp. Неожиданный и непонятный asyncio.exceptions.CancelledError

Писал многопоточную закачку и столкнулся с проблемой странного появления CancelledError. Частично удалось сделать воспроизводимый пример.

Эмулятор плохого сервера. С aiohttp сервером не работаю, поэтому нашел в инете и чуть поправил. Выдает кучу мусорных данных или эмулирует таймаут.

import asyncio
import datetime
import random
from aiohttp import web

async def uptime_handler(request):
    chance = random.randint(1, 4)
    if chance == 0:
        await asyncio.sleep(50)
        return
    response = web.StreamResponse()
    response.headers['Content-Type'] = 'text/html'
    await response.prepare(request)

    for i in range(100000):
        formatted_date = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        message = f'{formatted_date}<br>'
        await response.write(message.encode('utf-8'))
        await asyncio.sleep(0.01)

if __name__ == '__main__':
    app = web.Application()
    app.add_routes([ web.get('/', uptime_handler),])
    web.run_app(app)

Эмулятор клиента. Качает параллельно в 10 потоков. Пример обновлен.

import asyncio
import random
import traceback

import aiohttp

URL = 'http://127.0.0.1:8080/'

__http__: aiohttp.ClientSession = None
__semaphore__: asyncio.Semaphore = None

async def download_file(client, url: str):
    try:
        r = await client.get(url, timeout=random.randint(5, 15))
        async for data in r.content.iter_chunked(8192):
            await asyncio.sleep(0.01)
        stub = 2 + 2
        foo = "bar"  # garbage code
    except asyncio.exceptions.TimeoutError:
        try:
            print(f'catch TIMEOUT in download_file')
            await asyncio.sleep(5)
        except asyncio.exceptions.CancelledError:
            print('ALARM ' + traceback.format_exc())

async def _download(client, idx) -> int:
    try:
        for i in range(10):
            print(f'{idx} start')
            try:
                await download_file(client, URL)
            except aiohttp.client.ClientPayloadError:
                break
    except BaseException as e:
        print('**********' + traceback.format_exc())
    finally:
        print(f'{idx} finish')
        __semaphore__.release()
    return idx

async def main():
    global __semaphore__
    __semaphore__ = asyncio.BoundedSemaphore(10)
    async with aiohttp.ClientSession()as client:
        i = 0
        while True:
            i += 1
            await __semaphore__.acquire()
            asyncio.ensure_future(_download(client, i % 10))


if __name__ == '__main__':
    asyncio.run(main())

Ошибка выглядит вот так

1 start
...
0 start
catch TIMEOUT in download_file
catch TIMEOUT in download_file
catch TIMEOUT in download_file
catch TIMEOUT in download_file
catch TIMEOUT in download_file
catch TIMEOUT in download_file
8 start
...
5 start
0 start
catch TIMEOUT in download_file
catch TIMEOUT in download_file
ALARM Traceback (most recent call last):
  File "F:\dev\test\asynciotest\test_sleep3.9.py", line 15, in download_file
    async for data in r.content.iter_chunked(8192):
  File "C:\Python\lib\site-packages\aiohttp\streams.py", line 39, in __anext__
    rv = await self.read_func()
  File "C:\Python\lib\site-packages\aiohttp\streams.py", line 380, in read
    await self._wait("read")
  File "C:\Python\lib\site-packages\aiohttp\streams.py", line 305, in _wait
    with self._timer:
  File "C:\Python\lib\site-packages\aiohttp\helpers.py", line 641, in __enter__
    raise asyncio.TimeoutError from None
asyncio.exceptions.TimeoutError

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "F:\dev\test\asynciotest\test_sleep3.9.py", line 22, in download_file
    await asyncio.sleep(5)
  File "C:\Python\lib\asyncio\tasks.py", line 654, in sleep
    return await future
asyncio.exceptions.CancelledError

3 start

В какой-то момент await asyncio.sleep(5) может выдать CancelledError. Кто и почему (и как) отменяет эту корутину?

У меня проблема на win10+py3.9.6, а также на centos+py3.8.3/3.9.6 (и на 3.7.9)


Ответы (1 шт):

Автор решения: Georgiy

Решил подробнее копнуть вопрос и я пришел к следующим выводам:

Нашёл актуальный на данный момент времени вопрос в их github репозитории и там один из контрибьютеров этого репозитория пишет, что при обрыве соединения/таймауте сопрограмма (корутина) принудительно закрывается и поэтому выбрасывает еще CancelledError. Эта ошибка из модуля asyncio, и раньше она обрабатывалась "под капотом" и выкидывалась одна TimeoutError, игнорируя CancelledError.

Это баг репозитория, который появился с версии python 3.8+, а так как эта ошибка относится к модулю asyncio, она тут появляется из-за изменения наследования класса ошибки (с версии 3.8 она относиться к BaseException вместо Exception как раньше).

Упрощённо говоря, BaseException уровнем выше, чем Exception и поэтому дополнительно выходит этот traceback из модуля asyncio, поэтому остаётся ждать когда они поправят этот баг или делать дегрейд до python версии 3.6-3.7

→ Ссылка