Learning Platform
Урок 05.03 · 22 мин
Начальный
DecoratorsfunctoolsHigher-order functionsRetry pattern
Декораторы и замыкания: механизм под капотом Rate limiting и retry в REST API

Зачем DE декораторы

Внешний мир ненадёжен. REST API третьей стороны падает каждый сотый запрос. БД иногда отвечает за 30 секунд, иногда — за 30 миллисекунд. Файл, который вы скачиваете, может прийти битым. И на каждую такую функцию хочется навесить — повторить N раз, замерить время, залогировать вход/выход, проверить, что вызов идёт из главного потока. Если писать это в теле каждой функции — код превратится в кашу.

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

@retry(times=3, delay=1.0)
@timeit
def fetch_users(api_url: str) -> list[dict]:
    response = httpx.get(api_url, timeout=10)
    response.raise_for_status()
    return response.json()

Сама функция ничего не знает ни о ретраях, ни о замерах. Это вынесено наружу, и каждую обвязку можно тестировать отдельно от бизнес-логики.

Что такое декоратор технически

Главная идея: в Python функции — это объекты первого класса. Их можно класть в переменные, передавать аргументами, возвращать из других функций. Декоратор — это просто функция, которая:

  1. Принимает другую функцию как аргумент;
  2. Возвращает какую-то функцию (обычно — обёрнутую).

Самый простой декоратор — без аргументов и без эффекта:

def identity(func):
    return func

@identity
def hello():
    print("hi")

hello()   # "hi"

Запись @identity ровно эквивалентна hello = identity(hello). Питон видит @, вычисляет правую часть, передаёт туда функцию ниже и кладёт результат обратно под её именем.

Теперь сделаем что-то полезное — декоратор @log_call, который печатает аргументы каждого вызова:

def log_call(func):
    def wrapper(*args, **kwargs):
        print(f"call {func.__name__}(args={args}, kwargs={kwargs})")
        result = func(*args, **kwargs)
        print(f"return {func.__name__} -> {result!r}")
        return result
    return wrapper

@log_call
def add(a, b):
    return a + b

add(2, 3)
# call add(args=(2, 3), kwargs={})
# return add -> 5

Что здесь происходит, шаг за шагом. log_call — это и есть декоратор: он принимает func (нашу add) и возвращает wrapper. После @log_call имя add указывает уже не на исходную функцию, а на wrapper. Когда снаружи зовут add(2, 3), на самом деле зовётся wrapper(2, 3). Внутри wrapper имя func благодаря

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

Что делает @log_call с функцией

Снаружи имя add указывает на wrapper. Wrapper держит ссылку на исходную функцию через замыкание.

доadd = function(a, b)Чистая исходная функция
@log_calladd = log_call(add)
послеadd = wrapperИмя add теперь указывает на обёртку
вызов add(2,3)идёт в wrapper
wrapperпечатает входы → вызывает func(2,3) → печатает выход → возвращает результат

*args, **kwargs в wrapper — стандартная защита: позволяет обернуть любую функцию, с любой сигнатурой. Без этого декоратор работал бы только с конкретной сигнатурой.

Зачем нужен functools.wraps

С простым декоратором есть скрытая проблема. Посмотрим внимательно:

@log_call
def add(a, b):
    """Return sum of a and b."""
    return a + b

print(add.__name__)   # 'wrapper'  — !
print(add.__doc__)    # None       — !

Имя и docstring исходной функции потерялись. Внешне всё работает, но help(add) теперь покажет документацию wrapper, IDE-подсказки сломаются, ошибки в трейсбеке станут менее читаемыми.

Решение — декоратор functools.wraps. Он копирует с исходной функции имя, docstring, аннотации и пару других атрибутов:

from functools import wraps

def log_call(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"call {func.__name__}(args={args}, kwargs={kwargs})")
        result = func(*args, **kwargs)
        print(f"return {func.__name__} -> {result!r}")
        return result
    return wrapper

@log_call
def add(a, b):
    """Return sum of a and b."""
    return a + b

print(add.__name__)   # 'add'
print(add.__doc__)    # 'Return sum of a and b.'

Правило, без исключений: при написании любого собственного декоратора оборачивайте wrapper в @wraps(func). Если забыли — вы оставили мину для пользователей вашей функции.

Декоратор с аргументами

@retry(times=3, delay=1.0) — это уже не просто декоратор. Это функция, которая возвращает декоратор. Когда Python видит @something(...), он:

  1. Вычисляет something(...) — получает «настоящий» декоратор.
  2. Применяет результат к функции ниже.

То есть нужно три уровня вложенности: внешняя функция принимает параметры, средняя — функцию, внутренняя — реальные аргументы вызова.

import time
from functools import wraps

def retry(times: int = 3, delay: float = 1.0):
    """Повторить функцию до `times` раз при исключении, с задержкой между попытками."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_exc = None
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as exc:
                    last_exc = exc
                    if attempt < times:
                        print(f"attempt {attempt} failed: {exc}; retrying in {delay}s")
                        time.sleep(delay)
            raise last_exc
        return wrapper
    return decorator

@retry(times=3, delay=0.5)
def fetch_users():
    response = httpx.get("https://api.example.com/users", timeout=5)
    response.raise_for_status()
    return response.json()

Прочтите ещё раз цепочку: retry(times=3, delay=0.5) — это вызов с параметрами, он возвращает decorator. decorator(fetch_users) — это уже настоящий декоратор, возвращает wrapper. И только wrapper будет вызываться, когда снаружи кто-то напишет fetch_users().

NOTE

В production-коде на ретраях обычно пользуются библиотекой tenacity — она умеет экспоненциальный backoff, jitter, фильтрацию по типам ошибок, лимиты по суммарному времени. Мы детально разберём её в Module 06. А свой @retry мы пишем, чтобы понять, как такие декораторы устроены изнутри.

Exponential backoff в пять строк

Слегка прокачаем @retry: пусть задержка растёт между попытками. Это стандартный паттерн в работе с нестабильными API — даёт серверу время прийти в себя:

def retry(times: int = 3, base_delay: float = 0.5):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception as exc:
                    if attempt == times - 1:
                        raise
                    sleep_for = base_delay * (2 ** attempt)  # 0.5, 1, 2, 4...
                    print(f"retry in {sleep_for}s: {exc}")
                    time.sleep(sleep_for)
        return wrapper
    return decorator

Каждая следующая попытка ждёт в два раза дольше — это и есть

exponential backoff
. Тот же подход реализован под капотом tenacity, AWS SDK, kubectl и почти любого production-клиента.

Stacking декораторов

Декораторы можно ставить друг на друга — порядок имеет значение:

@retry(times=3)
@timeit
def fetch_users():
    ...

Это эквивалентно fetch_users = retry(times=3)(timeit(fetch_users)). То есть ближайший к функции декоратор применяется первым, потом — следующий, и так наружу.

В этом примере timeit оборачивает исходную функцию, retry оборачивает уже обёрнутую timeit-версию. Каждая попытка retry будет измерена timeit’ом. Если порядок поменять — timeit измерит сразу всю серию ретраев как один вызов. Меняйте порядок, исходя из смысла.

Готовые декораторы из stdlib

Свои декораторы писать круто, но 80% случаев уже закрыты библиотечными.

@functools.cache — мемоизация без лимита: первая вычисленная пара (args, kwargs) → result сохраняется в словаре, последующие вызовы с теми же аргументами берут готовое.

from functools import cache

@cache
def fibonacci(n: int) -> int:
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

fibonacci(100)   # без кэша висло бы вечность; с кэшем — мгновенно

Условие применимости: функция должна быть чистой — одни и те же аргументы всегда дают один и тот же результат. Аргументы должны быть hashable. Если функция читает БД или ходит в сеть — там кэш на уровне процесса часто это баг, а не фича.

@functools.lru_cache(maxsize=128) — то же самое, но с ограничением размера. Выбрасывает наименее недавно использованную пару, когда переполняется. Используют, если результаты тяжёлые и неограниченный рост кэша опасен.

@staticmethod — функция внутри класса, которая не получает ни self, ни cls. Просто живёт в namespace класса.

@classmethod — функция внутри класса, которая получает первым аргументом сам класс (cls), а не экземпляр. Используется для альтернативных конструкторов: User.from_dict(...), Path.cwd().

@property — превращает метод в атрибут. Снаружи пишется obj.name, под капотом срабатывает функция. Удобно для ленивых/вычисляемых атрибутов; глубже разберём в модуле про типы.

DE-кейс: @timeit для профилирования

Один из самых полезных собственных декораторов — замер времени функции. По духу — то же, что CM timing() из прошлого урока, только для функций целиком:

import time
from functools import wraps

def timeit(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            elapsed = time.perf_counter() - start
            print(f"{func.__name__}: {elapsed:.3f}s")
    return wrapper

@timeit
def aggregate_orders(path: str) -> dict[str, int]:
    ...

Развернуть в production-вариант — заменить print на logger.info(...) со структурным форматом (про логирование — в уроке 06), и вынести time.perf_counter() в вспомогательный CM, чтобы переиспользовать. Базовый шаблон — этот.

Anti-pattern: декораторы для бизнес-логики

Декораторы выглядят настолько круто, что хочется засунуть в них всё. Не надо.

Плохо:

@require_admin
@audit_action("delete_user")
@validate_input(schema=DeleteUserSchema)
def delete_user(user_id: int):
    db.execute("DELETE FROM users WHERE id = ?", (user_id,))

Что не так. Проверка прав доступа — это бизнес-логика, а не cross-cutting concern. Когда она в декораторе, её невозможно увидеть, не разворачивая всю обвязку. Тестировать delete_user без @require_admin нельзя — он мокается только на уровне самого декоратора. Поведение функции зависит от того, какие декораторы навешаны и в каком порядке. Через год новый разработчик попробует переиспользовать delete_user из админ-скрипта и неделю будет ловить непонятный PermissionError.

Здоровый паттерн — оставлять в декораторах только техническую обвязку, инфраструктурную, лишённую знания о домене:

  • ОК: @timeit, @retry, @cache, @log_call — это про инструментарий, не про данные.
  • Не ОК: @require_admin, @bill_user, @send_notification — здесь решения зависят от состояния системы.

Простое правило: если декоратор смотрит в саму функцию или в её аргументы по смыслу (читает поле user_id, проверяет роль) — это бизнес-логика, и ей лучше быть явной строкой в начале функции, а не магией снаружи.

Подводные камни

Декоратор выполняется в момент определения функции, а не в момент её вызова. Если внутри декоратора есть тяжёлая работа — она случится один раз на импорт.

Декорированная функция — это не та же самая функция. add is original_add после декорации — False. Это иногда мешает мокать в тестах: моки нужно ставить на ту версию, которая в namespace модуля.

Декораторы плохо стекаются с другими протоколами. Например, @classmethod имеет особое поведение, и его нельзя оборачивать произвольным wraps. Если декорируете методы — проверяйте, всё ли срабатывает.

@functools.cache на методах класса держит ссылку на self, и объекты этим методом не собираются GC. Используйте cache только для чистых функций без self.

Что мы получили

  • Декоратор — это функция, которая принимает функцию и возвращает функцию. Никакой магии — просто HOF и замыкания.
  • Шаблон с тремя уровнями вложенности — стандарт для декораторов с аргументами.
  • @functools.wraps — обязательная гигиена, без неё страдают трейсбеки и интроспекция.
  • В stdlib есть готовые декораторы: cache, lru_cache, staticmethod, classmethod, property.
  • DE-применение: @retry для нестабильного API, @timeit для профилирования, @cache для чистых вычислений.
  • Не суйте бизнес-логику в декораторы — там должна жить только cross-cutting инфраструктура.

В следующем уроке — pathlib: современная замена os.path для работы с путями и файловой системой.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6