Асинхронность на практике
таймауты, параллельные запросы и как это ломается
Что это
Статья 2.10 объясняла, что такое async и await. Здесь — три вещи, которые
нужны в реальном коде и которые ИИ пишет неправильно чаще всего: параллельный
запуск, обработка ошибок при нём и таймауты.
Тема практическая: почти весь асинхронный код в боте или сервисе состоит из этих трёх вещей.
Как выглядит в коде
import asyncio
async def fetch(city: str, delay: float) -> str:
await asyncio.sleep(delay)
if city == "Bergen":
raise RuntimeError("сервер вернул 500")
return f"{city}: +4"Параллельный запуск и его ловушка
try:
await asyncio.gather(fetch("Oslo", 0.1), fetch("Bergen", 0.1))
except RuntimeError as e:
print("gather упал целиком:", e)gather упал целиком: сервер вернул 500Вот что здесь важно: одна неудача убила весь набор. Запрос к Осло прошёл
успешно, но его результат потерян — gather поднял первое исключение и
выбросил остальное.
Для бота, который опрашивает десять городов, это значит: один недоступный сервер — и пользователь не получает ничего.
Как надо
results = await asyncio.gather(
fetch("Oslo", 0.1), fetch("Bergen", 0.1), fetch("Tromsø", 0.1),
return_exceptions=True,
)
for r in results:
print(" ошибка:" if isinstance(r, Exception) else " ок:", r) ок: Oslo: +4
ошибка: сервер вернул 500
ок: Tromsø: +4return_exceptions=True превращает исключения в обычные элементы списка.
Теперь успешные результаты сохранены, а неудачные можно обработать по
отдельности.
Плата за это — проверка isinstance(r, Exception) при разборе. Без неё
исключение уйдёт дальше как обычное значение и всплывёт в неожиданном месте.
Таймауты
Запрос, который не вернётся никогда, остановит обработчик навсегда:
try:
await asyncio.wait_for(fetch("Oslo", 2.0), timeout=0.3)
except TimeoutError:
print("не дождались за 0.3 с")не дождались за 0.3 сЛюбой сетевой вызов должен иметь таймаут. Библиотеки обычно дают свой
(aiohttp.ClientTimeout), но если его не задать явно, значение по умолчанию
может оказаться минутами.
Три правила
| Правило | Почему |
|---|---|
Независимые запросы — через gather | втрое быстрее на трёх запросах (статья 2.10) |
gather — с return_exceptions=True | одна ошибка не должна терять остальные |
| Любой сетевой вызов — с таймаутом | иначе обработчик висит вечно |
Как сказать ИИ
Эти три запроса независимы. Сделай их через asyncio.gather
с return_exceptions=True, разбери результаты: успешные собери
в список, неудачные запиши в лог с названием города.Добавь таймаут 5 секунд на каждый сетевой вызов.
При таймауте — предупреждение в лог и пропуск этого города,
а не падение всей команды.Проверка готового кода:
Что произойдёт в этом коде, если один из трёх серверов
не ответит? А если не ответит вообще никогда?Второй вопрос ловит отсутствие таймаута — то, чего не видно ни при запуске, ни в тестах.
На что смотреть
gather без return_exceptions. Главный признак. Проверяется взглядом.
Нет таймаута. Ищи ClientTimeout, timeout=, wait_for. Если ничего
нет — таймаута нет.
Цикл с await вместо gather. Запросы идут по очереди, хотя могли бы
разом. Не ошибка, но втрое медленнее (статья 2.10).
Синхронная библиотека внутри async def. requests, time.sleep,
обычный open — блокируют всё. Асинхронные замены: aiohttp, httpx,
aiosqlite, aiofiles.
asyncio.run внутри уже запущенного цикла. Ошибка
RuntimeError: asyncio.run() cannot be called from a running event loop
означает, что код скопирован из примера для скрипта в обработчик бота.
Задача создана, но не дождались. asyncio.create_task(...) без await
где-либо дальше: задача может не успеть выполниться до завершения программы.
Шпаргалка
Независимые запросы — через
gather, и обязательноreturn_exceptions=True.Без него одна ошибка теряет все успешные результаты.
Разбирая результаты
gather, проверяйisinstance(r, Exception).Любой сетевой вызов — с таймаутом, иначе обработчик висит вечно.
Проверочный вопрос: «что если один сервер не ответит никогда?»
requestsиtime.sleepвнутриasync defблокируют всё.