Знаете это чувство, когда ваш асинхронный Python скрипт завершается с кодом 0, но в логах ни одной строчки? Вы запускаете сбор данных с десяти API-эндпоинтов, ждете результат, а получаете пустой список или зависание. Виновник часто скрыт глубоко внутри asyncio: необработанное исключение в фоновой задаче просто исчезает, как будто его и не было.
В синхронном коде ошибка бросается прямо вам в лицо, останавливая выполнение программы. В асинхронном мире все иначе. Задачи (Tasks) живут своей жизнью, и если вы не скажете интерпретатору, куда девать ошибку, она может быть проигнорирована или записана только в системный лог, который никто не читает. Эта статья разберет механику работы с исключениями в корутинах, покажет типичные ловушки и даст рабочие паттерны для надежного кода на Python 3.12+.
Почему обычные try-except не всегда спасают
Многие новички переносят привычки из синхронного программирования напрямую в async/await. Они пишут блок try-except вокруг вызова функции, думая, что это покроет все риски. Но здесь есть нюанс: исключение выбрасывается только тогда, когда корутина действительно выполняется. Если вы создали задачу через asyncio.create_task(), она ставится в очередь событийного цикла. Исключение возникнет позже, возможно, уже после того, как основной поток продолжит работу.
Представьте ситуацию: вы запускаете пять параллельных запросов к базе данных. Третий падает с ошибкой соединения. Если вы не ожидаете завершения этой конкретной задачи (await task) или не проверяете её статус, интерпретатор может даже не вывести трейсбек. Он просто пометит задачу как "завершенную с исключением". И вот тут начинается магия garbage collector'а: если на задачу больше нет ссылок, Python вызывает метод __del__, который печатает предупреждение "Task exception was never retrieved". Это не краш программы, а тихий крик о помощи, который легко пропустить.
Стратегия обработки ошибок в asyncio.gather
Самый частый способ запуска нескольких задач - использование asyncio.gather. По умолчанию поведение этой функции агрессивное: если хотя бы одна задача упадет, gather немедленно пробросит это исключение вверх по стеку, отменяя остальные незавершенные задачи. Для многих сценариев это удобно, но иногда нужно получить результаты успешных операций, игнорируя провалы отдельных частей.
Для этого используйте параметр return_exceptions=True. В этом случае gather никогда не выбрасывает исключение. Вместо этого он возвращает список, где на месте упавшей задачи будет стоять объект исключения. Вам придется вручную перебрать этот список и проверить каждый элемент через isinstance(result, Exception).
| Характеристика | По умолчанию (False) | return_exceptions=True |
|---|---|---|
| Поведение при ошибке | Выбрасывает первое встреченное исключение | Возвращает список со всеми результатами и исключениями |
| Остальные задачи | Отменяются автоматически | Продолжают выполняться до конца |
| Сложность обработки | Низкая (один блок try-except) | Высокая (ручная фильтрация результатов) |
| Лучший сценарий | Критические операции, где любая ошибка фатальна | Агрегация данных, где частичный успех лучше полного провала |
Ловушка «потерянных» задач и слабых ссылок
Есть одна специфическая проблема, которая убивает нервы разработчикам: потерянные ссылки на задачи. Функция asyncio.create_task() возвращает объект Task. Если вы не сохраните эту ссылку в переменную или структуру данных, на которую держит жизнь другой объект, задача может быть уничтожена сборщиком мусора еще до того, как успеет завершиться или выбросить ошибку. Python официально рекомендует сохранять активные задачи в глобальный набор или словарь.
Без явного хранения ссылки вы рискуете получить ту самую ошибку "never retrieved", но без возможности её перехватить. Как это исправить? Создайте простой менеджер задач:
- Объявите глобальный
setилиdictдля хранения активных задач. - При создании задачи добавляйте её в этот контейнер.
- Добавьте колбэк (
add_done_callback), который удаляет задачу из контейнера после завершения. - Внутри колбэка обязательно проверьте наличие исключения через
task.exception().
Этот подход гарантирует, что каждая задача проживет ровно столько, сколько нужно, и ни одна ошибка не утонет в тишине сборщика мусора.
Цепочки исключений и traceback
Когда исключение всплывает из глубины асинхронной цепочки, стандартный трейсбек в Python может выглядеть запутанным. Вы увидите кадры выполнения самого event loop, внутренние методы asyncio и только потом ваш код. Чтобы упростить отладку, важно понимать, как работают атрибуты __context__ и __cause__.
Если вы перехватываете исключение и бросаете новое, используя конструкцию raise NewError from original_error, вы сохраняете связь между ними. Без ключевого слова from Python попытается сам связать их через контекст, но это работает не всегда корректно в асинхронных генераторах или сложных декораторах. Всегда явно указывайте причину, особенно если вы оборачиваете низкоуровневые сетевые ошибки (например, ConnectionResetError) в высокоуровневые бизнес-ошибки вашего приложения.
Практический чек-лист надежного async-кода
Чтобы спать спокойно, внедряйте эти правила в свой стиль программирования. Они помогут избежать большинства инцидентов на проде.
- Никогда не игнорируйте возврат create_task. Присваивайте его переменной или передавайте в менеджер задач.
- Используйте TaskGroup (Python 3.11+). Это современный аналог
gather, который автоматически управляет жизненным циклом задач и корректно отменяет их при первой же ошибке, предотвращая утечки ресурсов. - Логируйте исключения в колбэках. Не полагайтесь на вывод в консоль. Настройте обработчик ошибок в логере, который перехватывает сообщения уровня WARNING с текстом "Task exception".
- Проверяйте таймауты. Ошибка
TimeoutError- самый частый гость в сети. Оборачивайте внешние вызовы вasyncio.wait_for()и обрабатывайте именно этот класс исключений отдельно от других сетевых проблем. - Избегайте блокирующих вызовов внутри try-блоков. Если вы случайно вызовете синхронную функцию (например,
time.sleepвместоawait asyncio.sleep), это заблокирует весь цикл событий, и другие задачи не смогут обработать свои исключения вовремя.
Заключение: контроль над хаосом
Асинхронность дает скорость, но забирает простоту контроля потока выполнения. Исключения перестают быть линейными событиями и становятся частью сложной системы управления состоянием задач. Ключ к стабильности - не пытаться заглушить ошибки везде подряд, а четко определить точки, где вы готовы принять частичный отказ, и места, где ошибка должна остановить процесс. Используйте современные инструменты вроде TaskGroup, следите за жизненным циклом объектов Task и всегда явно обрабатывайте результаты сборки. Тогда ваш асинхронный код станет предсказуемым и надежным.
Что делать, если вижу предупреждение "Task exception was never retrieved"?
Это значит, что задача завершилась с ошибкой, но вы нигде не вызвали await task или task.result(), чтобы забрать исключение. Исправление: убедитесь, что на каждую созданную задачу есть ссылка, и добавьте колбэк, который вызывает task.exception() и логирует её, если она не равна None.
Как безопасно отменить задачу при ошибке в другой задаче?
Используйте asyncio.TaskGroup (доступен с версии Python 3.11). Он автоматически отменяет все оставшиеся задачи в группе, если одна из них выбрасывает исключение. В более старых версиях нужно вручную вызывать task.cancel() для всех остальных задач в блоке finally или при перехвате исключения.
Можно ли использовать try-except внутри async generator?
Да, можно. Однако будьте осторожны с тем, кто потребляет генератор. Если потребитель прерывает чтение (break или выход из цикла), генератор должен корректно закрыть ресурсы. Убедитесь, что ваши блоки finally в генераторе не вызывают await-операций, которые могут зависнуть, если цикл событий занят.
Чем отличается CancelledError от обычного исключения?
CancelledError наследуется от BaseException, а не от Exception. Это сделано специально, чтобы его нельзя было случайно перехватить конструкцией except Exception. Отмена задачи - это управляемый сигнал остановки, а не аварийная ситуация. Перехватывать его стоит только для очистки ресурсов, после чего нужно либо подавить его (если задача завершилась штатно), либо пробросить дальше.