← все задачи

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

Частичный успех: что делать с ошибками в gather

Средний 15 минут return_exceptionsTaskGroupобработка ошибок

Условие

Есть список задач-запросов. Верните два списка: успешные результаты и ошибки с указанием, на каком входе они произошли. Одна неудача не должна отменять остальные.

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

  • Одна упавшая задача не отменяет другие
  • По ошибке видно, к какому входному элементу она относится
  • Порядок результатов соответствует порядку входа

Пример

ok, failed = await fetch_all(urls)

# ok     -> [{"id": 1}, {"id": 3}]
# failed -> [("https://.../2", ClientResponseError(500))]

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

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

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

Решение

import asyncio


async def fetch_all(urls, fetch):
    results = await asyncio.gather(
        *(fetch(url) for url in urls),
        return_exceptions=True,
    )

    succeeded, failed = [], []
    for url, result in zip(urls, results):
        if isinstance(result, Exception):
            failed.append((url, result))
        else:
            succeeded.append(result)

    return succeeded, failed


# Python 3.11+, когда нужно наоборот: первая ошибка отменяет остальные
async def fetch_all_or_fail(urls, fetch):
    async with asyncio.TaskGroup() as group:
        tasks = [group.create_task(fetch(url)) for url in urls]

    return [task.result() for task in tasks]

Почему так

Почему zip с исходным списком

  • gather возвращает результаты в порядке аргументов, поэтому позиция однозначно связывает ошибку с входом
  • Без этой связи «ClientError» в логе бесполезен: непонятно, какой именно URL сломался
  • Тот же приём работает для любых входов, не только URL: zip(inputs, results) — общий шаблон

Почему isinstance(result, Exception), а не BaseException

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

gather против TaskGroup

  • gather с return_exceptions — про частичный успех: «сделай что можешь, остальное покажи»
  • TaskGroup — про всё или ничего: первая ошибка отменяет остальные задачи и наверх летит ExceptionGroup
  • Выбор зависит от задачи: для сбора данных из десятка источников уместен первый, для распределённой транзакции — второй

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

  • Спросят про ExceptionGroup и except* — новый синтаксис 3.11 для разбора групп исключений
  • Спросят, что делать с частичным успехом дальше: повторить только упавшие, и тут всплывает retry с backoff
  • Могут спросить про «оборванные» задачи: если вы вышли по первой ошибке без TaskGroup, остальные продолжат выполняться — их нужно отменить руками

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

Очередь: производитель и потребители — Классическая схема обработки потока: проверяют обратное давление и корректное завершение.