← все задачи

Async Python · задача 3 из 10

Таймаут и отмена

Средний 15–20 минут wait_forCancelledErrorочистка ресурсов

Условие

Напишите функцию, которая вызывает медленную операцию с таймаутом. Если операция не уложилась, вернуть значение по умолчанию, а саму операцию корректно отменить и освободить занятые ей ресурсы.

Что требуется

  • Таймаут задаётся параметром
  • После таймаута операция действительно останавливается, а не продолжает работать в фоне
  • Ресурсы (соединение, файл, блокировка) освобождаются

Пример

result = await with_timeout(slow_call(), timeout=2, default=None)
# вернёт результат или None через 2 секунды

Сначала уточните

Вопросы до кода — половина оценки. Молча начать печатать хуже, чем задать два вопроса.

  • Что важнее при таймауте: вернуть значение по умолчанию или пробросить ошибку наверх?
  • Операция идемпотентна? Если это списание денег, «отменил, но оно выполнилось» — большая проблема
  • Таймаут на одну операцию или общий на всю цепочку?
Показать решение Скрыть решение

Решение

import asyncio
import logging

logger = logging.getLogger(__name__)


async def with_timeout(coro, timeout, default=None):
    try:
        return await asyncio.wait_for(coro, timeout=timeout)
    except asyncio.TimeoutError:
        logger.warning("операция не уложилась в %sс", timeout)
        return default


# Как правильно писать саму операцию, чтобы её можно было отменить:
async def slow_call(session, url):
    try:
        async with session.get(url) as response:
            return await response.json()
    except asyncio.CancelledError:
        logger.info("запрос к %s отменён", url)
        raise                     # обязательно пробрасываем дальше
    finally:
        # сюда попадаем и при отмене: закрываем то, что открыли
        pass


# Python 3.11+: таймаут на блок целиком
async def with_timeout_block(session, urls, timeout):
    async with asyncio.timeout(timeout):
        return [await slow_call(session, url) for url in urls]

Почему так

Как устроена отмена в asyncio

  • wait_for по истечении времени вызывает cancel у задачи: внутрь корутины на ближайшем await бросается CancelledError
  • Это обычное исключение, поэтому отработают все finally и async with — ресурсы закроются
  • Прерывание возможно только на точках await: блокирующий вызов посреди корутины отменить нельзя, он доработает до конца

Почему CancelledError нужно пробрасывать

  • Если перехватить и не пробросить, задача формально продолжит выполнение, а вызывающий уже считает её отменённой
  • В 3.8+ CancelledError наследуется от BaseException именно для того, чтобы его не ловил случайный except Exception
  • Ловить его допустимо только ради логирования и очистки — и сразу raise

Почему таймаут не означает «операция не выполнилась»

  • Запрос мог дойти до сервера и быть обработан — вы просто не дождались ответа
  • Для неидемпотентных операций это означает возможное двойное выполнение при повторе
  • Отсюда ключ идемпотентности в платёжных API: это ровно та причина, по которой он существует

Что спросят дальше

  • Спросят про shield: он защищает критическую часть от отмены — и тут же спросят, когда это оправдано
  • Спросят разницу wait_for и asyncio.timeout: второй ограничивает блок, а не одну корутину, и не заворачивает её в задачу
  • Спросят, как отменить блокирующий вызов в to_thread — правильный ответ: никак, поток доработает

Следующая задача

Частичный успех: что делать с ошибками в gather — Нужно вернуть удачные результаты и внятно отчитаться по неудачным, а не упасть целиком.