← все задачи

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

Повторы для асинхронного вызова

Средний 15 минут декораторыasyncio.sleepотмена

Условие

Напишите асинхронный декоратор retry: повторяет корутину при сетевых ошибках, пауза растёт, отмена во время паузы должна работать мгновенно.

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

  • Работает с любой корутиной
  • Пауза между попытками растёт экспоненциально
  • Отмена задачи во время паузы прерывает ожидание немедленно

Пример

@async_retry(attempts=3, delay=0.5, exceptions=(aiohttp.ClientError, asyncio.TimeoutError))
async def fetch(session, url):
    async with session.get(url) as response:
        return await response.json()

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

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

  • Какие исключения считаем временными: только сетевые или ещё и 5xx?
  • Нужен ли общий бюджет времени на все попытки, а не только число попыток?
  • Идемпотентна ли операция — можно ли её вообще повторять?
Показать решение Скрыть решение

Решение

import asyncio
import functools
import random


def async_retry(attempts=3, delay=0.5, backoff=2, max_delay=30,
                exceptions=(Exception,), jitter=True):
    def decorator(func):
        @functools.wraps(func)
        async def wrapper(*args, **kwargs):
            current_delay = delay

            for attempt in range(1, attempts + 1):
                try:
                    return await func(*args, **kwargs)
                except asyncio.CancelledError:
                    raise                       # отмена — не повод повторять
                except exceptions:
                    if attempt == attempts:
                        raise

                    pause = min(current_delay, max_delay)
                    if jitter:
                        pause *= random.uniform(0.5, 1.5)

                    await asyncio.sleep(pause)  # не блокирует цикл событий
                    current_delay *= backoff

        return wrapper
    return decorator

Почему так

Почему asyncio.sleep, а не time.sleep

  • time.sleep блокирует весь поток: встанет не только эта корутина, а весь цикл событий вместе со всеми соединениями
  • На одном воркере это означает, что сервис перестал отвечать всем клиентам на время паузы
  • asyncio.sleep — точка await: цикл событий в это время обслуживает остальных

Почему CancelledError перехвачен отдельно

  • Если в exceptions попадёт Exception, а CancelledError не выделить, отмена превратится в очередную попытку
  • Отменённая задача обязана завершиться, а не начать новый запрос с паузой
  • В 3.8+ CancelledError не наследуется от Exception, но явная ветка делает намерение читаемым и защищает от except BaseException

Почему отмена во время паузы срабатывает мгновенно

  • await asyncio.sleep — точка, в которой корутина отдаёт управление, значит туда можно доставить CancelledError
  • С time.sleep отмена ждала бы конца паузы: задача считается отменённой, а поток всё ещё спит
  • Это хороший ответ на вопрос «чем плох блокирующий вызов в асинхронном коде», кроме очевидной потери конкурентности

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

  • Спросят про общий бюджет: deadline на все попытки честнее, чем число попыток, — особенно если сверху уже есть таймаут запроса
  • Спросят про 429 и Retry-After: сервер сам говорит, сколько ждать, и это важнее вашей экспоненты
  • Спросят, чем плохи повторы под нагрузкой: ретраи умножают трафик на упавший сервис — отсюда circuit breaker

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

Блокирующий код внутри асинхронного — Любимый вопрос: в асинхронный обработчик попал синхронный вызов — что произошло и как чинить.