← все задачи

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

Декоратор retry с экспоненциальной паузой

Средний 20 минут декораторыfunctools.wrapsобработка ошибок

Условие

Напишите декоратор retry: он повторяет вызов функции при исключении заданных типов, делая паузу, которая растёт с каждой попыткой. После исчерпания попыток исключение пробрасывается наружу.

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

  • Число попыток и стартовая пауза настраиваются
  • Повторяем только указанные типы исключений, остальные пробрасываем сразу
  • Имя и docstring исходной функции сохраняются

Пример

@retry(attempts=3, delay=0.5, exceptions=(ConnectionError,))
def fetch(url):
    return requests.get(url, timeout=2).json()

# паузы между попытками: 0.5, 1.0 — потом исключение наружу

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

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

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

Решение

import functools
import random
import time


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

            for attempt in range(1, attempts + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions:
                    if attempt == attempts:
                        raise                      # попытки кончились — отдаём исходную ошибку

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

                    time.sleep(pause)
                    current_delay *= backoff

        return wrapper
    return decorator

Почему так

Почему три уровня вложенности

  • У декоратора есть параметры, поэтому нужен ещё один уровень: retry(...) возвращает декоратор, тот — обёртку
  • Без параметров хватило бы двух уровней — об этом стоит сказать, объясняя структуру
  • Просьба «сделай так, чтобы работало и с параметрами, и без» — частое продолжение; решается проверкой, что первый аргумент вызываемый

Зачем functools.wraps

  • Без него fetch.__name__ станет "wrapper", а docstring исчезнет — сломаются логи, Sphinx и интроспекция
  • Особенно больно с Django и Celery: они опираются на имя функции при регистрации задач
  • Отсутствие wraps — первое, к чему придерётся интервьюер, и придирка справедливая

Почему пауза растёт и зачем джиттер

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

Почему повторяем не всё подряд

  • Повторять имеет смысл только временные ошибки: таймаут, обрыв соединения, 503
  • Повтор на 400 или ValueError бессмысленен — ответ не изменится, вы просто утроите нагрузку
  • Отдельная опасность — неидемпотентные операции: повтор списания денег недопустим, и про идемпотентность спросят обязательно

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

  • Спросят про async-версию: та же структура, но await func(...) и asyncio.sleep
  • Спросят, почему не готовая библиотека (tenacity) — правильный ответ: в проде взял бы её, здесь показываю механику
  • Могут попросить логировать попытки — покажите, что логируете номер попытки и тип ошибки, а не «сломалось»

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

Контекстный менеджер для замера времени — Проверяют протокол with, поведение при исключении и знание contextlib.