8.6. Исключения

Исключения внутри asyncio-скрипта ведут себя почти так же, как в обычном Python – они распространяются вверх по цепочке вызовов, пока что-нибудь их не перехватит. Почти потому, что задачи выполняются параллельно, поэтому путь «вверх» – это не тот путь, который создал задачу. На этой странице рассматривается, куда уходят исключения в каждой из распространённых схем.

8.6.1. Внутри одной сопрограммы

Конструкция try/except внутри сопрограммы перехватывает исключения, возникшие в любом await-выражении, обычным образом:

async def fetch_with_retry(url):
    for attempt in range(3):
        try:
            return await fetch(url)
        except OSError as e:
            last_error = e
    raise last_error

Здесь нет ничего специфичного для asyncio – предложение except видит исключения, возникшие внутри fetch, так же, как если бы это был обычный вызов функции.

8.6.2. В задаче, которую приложение ожидает

Когда сопрограмма, выполняющаяся как Task, возбуждает исключение, оно сохраняется в задаче. В следующий раз, когда что-нибудь сделает awaitэтой задачи, исключение будет повторно возбуждено в точке await:

task = asyncio.create_task(may_fail())
try:
    result = await task
except OSError:
    log("may_fail failed")

То же самое относится к asyncio.gather(). Поведение по умолчанию – один дочерний элемент возбуждает исключение, остальные отменяются, а исключение распространяется наружу из gather – основано на этом механизме.

8.6.3. В задаче, которую никто не ожидает

Задача, которую никто никогда не ожидает через await, – это случай, требующий внимания. Исключение всё равно происходит; цикл замечает, что задача завершилась с необработанным исключением; но нет await, в котором оно могло бы всплыть. Поведение по умолчанию – напечатать трассировку через sys.stderr и продолжить выполнение – что подходит для автономной диагностики, но плохо годится для приложения, которое хотело об этом знать.

Правильное решение обычно – ожидать задачу. Либо напрямую, запомнив её дескриптор и ожидая его при завершении работы, либо неявно через gather() или wait_for(). Шаблон «завершение работы приложения» со страницы Тайм-ауты и отмена охватывает этот случай для долгоживущих фоновых задач, которые порождает типичный скрипт.

8.6.4. Пользовательский обработчик исключений

Когда молчаливого вывода трассировки и продолжения недостаточно, цикл предоставляет точку расширения – Loop.set_exception_handler – которую приложение может переопределить, чтобы сделать что-то другое:

def handler(loop, context):
    print("asyncio:", context.get("message"))
    if "exception" in context:
        sys.print_exception(context["exception"])

loop = asyncio.get_event_loop()
loop.set_exception_handler(handler)

Аргумент context – это словарь с ключами 'message', 'exception' и 'future'. Исключение может отсутствовать при определённых событиях типа предупреждений, поэтому в примере используется .get().

Типичные применения – записать сбой во флеш-память, мигнуть светодиодом ошибки или эскалировать до перезагрузки по сторожевому таймеру. Страница управление циклом охватывает всю поверхность точек расширения цикла.

8.6.5. KeyboardInterrupt

Когда скрипт останавливается извне – обычно по запросу IDE на остановку – запрос приходит внутрь скрипта в виде KeyboardInterrupt. Внутри asyncio.run() он распространяется так же, как любое другое необработанное исключение: main отменяется, каждая задача, которую отслеживает цикл, тоже отменяется, и KeyboardInterrupt повторно возбуждается из asyncio.run(). Предложения finally выполняются на выходе, поэтому тот же шаблон очистки со страницы об отмене обрабатывает этот случай.