Перейти к содержанию

192 уроков, 14 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 19 из 20 Средний 35 мин 120 XP

Читаем async-ошибки: словарь типовых сообщений

Сообщения asyncio пугают, пока их не переведёшь. Четыре типовые ошибки — причина, лечение, живой пример на каждую.

Редакция Питоники

Асинхронные traceback'ы многословны, а сообщения asyncio звучат как приговор: «never awaited», «never retrieved», «no running event loop». На деле это не экзотика, а четыре-пять типовых ситуаций, которые встречаются у каждого — просто у каждой есть точная причина и короткое лечение. Этот урок — словарь: сообщение, причина, лечение и запускаемый пример, где ошибку видно вживую. Словарь экономит часы: вместо гугления пугающей строки — знакомый пункт с точным лечением. Пробегись по таблице при первом чтении и вернись к ней, когда сообщение появится на твоём экране, — так словарь работает быстрее всего.

СообщениеПричинаЛечение
coroutine ... was never awaitedКорутину создали вызовом, но не запустилиawait перед вызовом или asyncio.create_task
Task exception was never retrievedЗадача упала, результат никто не забралawait task в try/except или task.exception()
RuntimeError: no running event loopasync-вызов вне asyncio.runВызывать из корутин, а не с верхнего уровня
TimeoutError от wait_forОперация не успела в дедлайнОбработать try/except или пересмотреть лимит
got Future attached to a different loopПримитив создан вне цикла или циклов дваСоздавать и использовать всё внутри одного asyncio.run

Never awaited: корутина, которую никто не запустил

Ошибка номер один, ей посвящён целый ранний урок, но повторить стоит: вызов async-функции не выполняет тело — он создаёт объект-корутину. Запускают его await или create_task. Без них тело молчит, а Python оставляет предупреждение в консоли. Разберём на паре «создал — запустил»:

создание корутины и её запуск
import asyncio

async def ping():
    await asyncio.sleep(1)
    return "pong"

async def main():
    coro = ping()                       # создали объект, тело НЕ работает
    print("это ещё не результат:", type(coro).__name__)
    result = await coro                 # await запускает тело
    print("а вот теперь результат:", result)

asyncio.run(main())
Вывод
это ещё не результат: coroutine
а вот теперь результат: pong

Строка coro = ping() отработала мгновенно и без ошибок — в этом коварство. Тело стартует только на await. А что будет, если await так и не случится? Запусти следующий пример и загляни в консоль целиком: на экране основного вывода будет одна строка, но над или под ней Python напечатает RuntimeWarning: coroutine 'ping' was never awaited. Предупреждение появляется не в момент ошибки, а при уборке мусора — когда никому не нужный объект-корутина уничтожается.

забытый await: где искать предупреждение
import asyncio

async def ping():
    await asyncio.sleep(1)
    return "pong"

async def main():
    ping()                              # забыли await - тело не стартует
    print("main закончился")

asyncio.run(main())
Вывод
main закончился
Смотри вывод интерпретатора целиком: помимо stdout, появится RuntimeWarning: coroutine 'ping' was never awaited — предупреждение печатается при уничтожении корутины, а не в момент вызова.

Для охоты на такие предупреждения у Python есть режим строгости: запуск с флагом -W error превращает предупреждения в ошибки, а -X dev включает подробную диагностику разработки. В тестах и CI это ловит забытый await на этапе прогона — не в продакшене, где warning тонет в простыне логов.

Task exception was never retrieved: упала, но никто не смотрел

У исключений в фоновых задачах свои правила: create_task запускает корутину, и если та падает — программа не взрывается. Исключение лежит внутри объекта задачи и ждёт, когда за ним придут с await. Не дождались до конца программы — Python напомнит сообщением «Task exception was never retrieved»: честный сигнал, что в фоне что-то сломалось, а ты не в курсе.

задача упала в фоне незамеченной
import asyncio

async def bad():
    await asyncio.sleep(1)
    raise ValueError("упала в фоне")

async def main():
    task = asyncio.create_task(bad())
    await asyncio.sleep(2)              # задача успела упасть, но никто не смотрел
    print("main закончился, задача упала незамеченной")

asyncio.run(main())
Вывод
main закончился, задача упала незамеченной
После этой строки, при завершении программы, Python напечатает «Task exception was never retrieved» с полным traceback фоновой задачи — исключение никто не забрал.

Исключение в фоновой задаче не взрывается сразу: оно лежит внутри объекта задачи, пока кто-нибудь не придёт за результатом. Лечение — забрать: оберни await задачи в try/except, и падение станет обычной ошибкой, которую ты обрабатываешь там же, где запускал.

лечение: await задачи под try/except
import asyncio

async def bad():
    await asyncio.sleep(1)
    raise ValueError("упала в фоне")

async def main():
    task = asyncio.create_task(bad())
    try:
        await task                      # забираем результат или исключение
    except ValueError as e:
        print("поймали ошибку задачи:", e)

asyncio.run(main())
Вывод
поймали ошибку задачи: упала в фоне

Второй способ — метод task.exception(): вызвать его можно, когда задача завершилась, и он вернёт исключение на руки, не поднимая его в твоём коде. Удобно для проверок после того, как основная работа кончилась.

Чтобы такие сообщения не всплывали неожиданно, у цикла событий есть свой обработчик: loop.set_exception_handler получает все необработанные случаи — включая never retrieved — и пишет их куда скажешь: в алерты, в файл, в систему мониторинга. По умолчанию всё уходит в логи встроенного логгера asyncio, которые редко кто читает, — вот и собираются сюрпризы к концу смены.

второе лечение: task.exception()
import asyncio

async def bad():
    await asyncio.sleep(1)
    raise ValueError("упала в фоне")

async def main():
    task = asyncio.create_task(bad())
    await asyncio.sleep(2)
    print("задача завершена:", task.done())
    exc = task.exception()              # забрали исключение - предупреждения не будет
    print("внутри задачи:", exc)

asyncio.run(main())
Вывод
задача завершена: True
внутри задачи: упала в фоне

RuntimeError: no running event loop

Часть asyncio-инструментов требует работающего цикла событий: create_task, Queue, Semaphore и их друзья планируют работу через цикл, а он живёт только внутри asyncio.run. Вызов с верхнего уровня файла — до запуска цикла или после его закрытия — даёт RuntimeError: no running event loop. Лечение одно: такие вызовы делаются из корутин, то есть внутри main.

одна и та же операция вне и внутри цикла
import asyncio

async def inner():
    return "данные"

try:
    asyncio.create_task(inner())        # вне asyncio.run цикла нет
except RuntimeError as e:
    print("RuntimeError:", e)

async def main():
    print("внутри asyncio.run задачи работают")

asyncio.run(main())
Вывод
RuntimeError: no running event loop
внутри asyncio.run задачи работают
В консоли к этому примеру добавится и RuntimeWarning: coroutine 'inner' was never awaited — корутина из неудавшегося create_task осталась не запущенной.

Из того же семейства — сообщение про «Future attached to a different loop»: примитив создали на одном цикле, а используют на другом. Причина почти всегда одна: два вызова asyncio.run в программе или примитив, созданный на верхнем уровне ещё до запуска цикла. Правило гигиены: один asyncio.run на программу, всё асинхронное создаётся и живёт внутри него.

Отдельная ловушка — ноутбуки и продвинутые REPL: там цикл событий уже крутится, и asyncio.run ответит «cannot be called from a running event loop». Формулировка другая, причина та же — конфликт циклов: в блокноте корутины дожидаются await прямо в ячейке, без asyncio.run, и create_task работает без обёрток.

TimeoutError: не баг, а сигнал

Таймауты из урока 9 при срабатывании бросают TimeoutError — и новичок часто принимает его за поломку. На деле это самый честный сигнал в списке: операция просто не уложилась в дедлайн, wait_for её отменил и сообщил об этом. Что с ним делать — решать тебе: увеличить лимит, повторить запрос или считать операцию неудачной и идти дальше.

TimeoutError бывает и не от wait_for: его бросают низкоуровневые операции сокетов и сторонние библиотеки со своими лимитами. Прежде чем лечить, посмотри traceback — кто источник: от этого зависит лечение. У wait_for это управление дедлайном, у библиотеки — её настройки повторов и лимитов, у сети — стабильность канала.

таймаут как штатная ситуация
import asyncio

async def slow():
    await asyncio.sleep(5)              # слишком долго для дедлайна
    return "успел"

async def main():
    try:
        await asyncio.wait_for(slow(), timeout=2)
    except TimeoutError:
        print("TimeoutError: не дождались за 2 с")
    print("программа продолжает работу")

asyncio.run(main())
Вывод
TimeoutError: не дождались за 2 с
программа продолжает работу

Обрати внимание: после перехвата программа жива — таймаут перехвачен, main дошёл до последней строки. Именно так строят устойчивые запросники: не «упало — упало», а «не успело — попробуем следующего поставщика».

Пачка ошибок: gather и return_exceptions

Когда операций много, забирать ошибки по одной утомительно. gather из урока 7 с параметром return_exceptions=True складывает и результаты, и исключения в один список — тип каждого элемента расскажет, чем кончилась операция. Так собирают отчёты по пачке запросов: посчитать успешные, разложить ошибки по типам.

Замечай заодно два запаха кода: голый gather без return_exceptions в пачке запросов почти всегда баг в ожидании — одна ошибка роняет отчёт; а фоновая задача без ожидания — потенциальный never retrieved. Оба случая не стреляют сразу, и именно поэтому их стоит проверять код-ревью и тестами: предупреждение дешевле аварии на проде.

результаты и ошибки в одном списке
import asyncio

async def may_fail(name, delay, fail):
    await asyncio.sleep(delay)
    if fail:
        raise RuntimeError(name + ": не получилось")
    return name + ": успех"

async def main():
    results = await asyncio.gather(
        may_fail("a", 1, False),
        may_fail("b", 2, True),
        may_fail("c", 3, False),
        return_exceptions=True,         # ошибки придут в списке результатов
    )
    for r in results:
        print(type(r).__name__, "-", r)

asyncio.run(main())
Вывод
str - a: успех
RuntimeError - b: не получилось
str - c: успех

Как читать сам traceback

И последнее умение из словаря — читать длинный traceback конкурентного кода. Правило то же, что в синхронном Python: начинай с последней строки — там класс и сообщение исключения; выше по списку «File ... line ...» — цепочка вызовов, приведшая к нему. В async-программе в середине может вклиниться служебная строка про задачу или таймаут — она показывает, через какой механизм исключение попало наружу, и тоже полезна.

разбор traceback с конца
trace = """
Traceback (most recent call last):
  File "bot.py", line 12, in handle
    data = await api.load(url)
  File "api.py", line 40, in load
    return await session.get(url)
TimeoutError: не дождались ответа
"""
lines = trace.strip().splitlines()
print("строк в traceback:", len(lines))
print("причина - последняя строка:", lines[-1])
print("место падения - предпоследняя:", lines[-2].strip())
Вывод
строк в traceback: 6
причина - последняя строка: TimeoutError: не дождались ответа
место падения - предпоследняя: return await session.get(url)

Чек-лист диагностики

  • прочитай последнюю строку traceback — там класс и сообщение ошибки;
  • ищи RuntimeWarning про never awaited рядом с вызовом корутины;
  • у каждой фоновой задачи — await в try/except или проверка exception();
  • один asyncio.run на программу, примитивы создаются внутри него;
  • TimeoutError — сигнал про дедлайн, а не про поломку программы.

Диагностика async-программы быстро сводится к чек-листу: прочитай последнюю строку traceback; поищи never awaited — забытый await; вспомни, не дождался ли ты фоновой задачи; проверь, один ли в программе asyncio.run; если TimeoutError — решай, лимит или операция. Этот чек-лист закрывает подавляющее большинство стартер-ошибок, а умение тестировать такие ситуации приходит из раздела про pytest: там же научишься проверять, что код падает именно с нужным исключением, а не с любым. Дальше — финальный проект курса, где все эти инструменты соберутся в один диспетчер задач.

Каждое пугающее сообщение asyncio — это точная диагностика: программа не может запустить корутину, не нашла цикл событий или не дождалась операции. Причина у сообщения одна, и лечение тоже.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

import asyncio

async def f():
    return 1

async def main():
    c = f()
    print(type(c).__name__)
    await c

asyncio.run(main())
import asyncio

async def fail():
    raise ValueError("x")

async def main():
    r = await asyncio.gather(fail(), return_exceptions=True)
    print(type(r[0]).__name__)

asyncio.run(main())
import asyncio

async def job():
    await asyncio.sleep(3)

async def main():
    try:
        await asyncio.wait_for(job(), timeout=1)
        print("успел")
    except TimeoutError:
        print("таймаут")

asyncio.run(main())
Проверь себя
0 / 6

1. Что означает RuntimeWarning: coroutine ... was never awaited?

2. Почему сообщение «Task exception was never retrieved» часто появляется в конце логов, а не сразу?

3. asyncio.create_task() на верхнем уровне файла даёт «RuntimeError: no running event loop». Как лечить?

4. wait_for бросил TimeoutError. Что это значит?

5. Что даёт gather(..., return_exceptions=True) при падении одной из корутин?

6. С какой строки стоит начинать чтение длинного traceback?

Карточки терминов
Запомнено: 0 / 6
Практика

Напиши обёртку safe(coro): она дожидается переданную корутину и возвращает её результат, а если та упала — строку «ошибка: <текст>» вместо исключения. Проверь на двух корутинах: bad() поднимает ValueError с текстом «boom», good() через секунду возвращает 42. Напечатай результат safe для каждой: сначала для bad, потом «ok:» и результат good.

practice.py
Вопросы и ответы по уроку

Почему предупреждение never awaited видно не сразу?

Оно печатается при уничтожении объекта-корутины сборщиком мусора, а не в момент вызова: вызов async-функции легален, просто бесполезен. Поэтому «неработающая» функция может молчать до конца программы. Привычка: после вызова async-функции сразу смотри, стоит ли await или create_task.

Как найти, какая именно фоновая задача упала?

Сообщение «Task exception was never retrieved» печатается вместе с traceback задачи — там видны файл и строка. Чтобы ловить падения предсказуемо, дожидайся задачи вручную: await task в try/except в месте, где она нужна, или собирай пачки через gather с return_exceptions=True.

Можно ли вызывать asyncio.run дважды в одной программе?

Технически можно, но не нужно: каждый вызов создаёт и закрывает новый цикл, а примитивы из первого цикла (очереди, задачи, семафоры) со вторым несовместимы — отсюда ошибки про «different loop». Один asyncio.run на программу, вся асинхронная работа — внутри его корутин.

Как тестировать, что код правильно обрабатывает асинхронные ошибки?

В pytest есть плагин pytest-asyncio: тесты объявляются async def и внутри них можно дожидаться корутин и проверять исключения через pytest.raises. Основы самого pytest — фикстуры, raises, параметризация — разбираются в нашем отдельном разделе: начни с первого урока, схемы те же, что и для синхронного кода.

Понравился урок? Сошлитесь на него

«Исключение в фоновой задаче не взрывается сразу: оно лежит внутри объекта задачи, пока кто-нибудь не придёт за результатом.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.