Ночной алерт приносит трейсбек из шести кадров, и четыре из них называются одинаково: wrapper в модуле utils, строка сорок два. Дежурный инженер открывает файл, видит каскад из трёх замыканий и тратит полчаса, чтобы понять, какой именно запрос упал и с каким аргументом. Декоратор с параметрами выглядит безупречно в учебном примере: строка @retry(times=5) над функцией заменяет сорок строк проверок и повторов. В большом коде у этой элегантности есть цена, и платится она не при написании, а ночью во время инцидента. Ниже механика фабрик декораторов, честные пределы functools.wraps, реальные трассировки стека, ловушки времени импорта и границы, за которыми метапрограммирование мешает команде больше, чем помогает.

Три уровня вложенных функций и механика фабрики декораторов с параметром

Запись со скобками разворачивается в два вызова. Выражение @retry(times=5) это сначала retry(times=5), возвращающее собственно декоратор, и уже он принимает целевую функцию. Отсюда три уровня: внешняя функция держит параметры, средняя получает функцию, внутренняя выполняется на каждом вызове. Классический повтор попыток для внешних вызовов выглядит так:

import functools
import time

def retry(times: int = 3, delay: float = 1.0):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception:
                    if attempt == times - 1:
                        raise
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(times=5, delay=0.5)
def fetch_report(day: str) -> bytes:
    ...

Каскады усложняют картину порядком: декораторы применяются снизу вверх, обёртывание идёт от нижнего к верхнему, и строка @retry над @timing означает, что повторяться будет уже инструментированный вызов. Поменять строки местами значит изменить поведение целиком, при том что выглядит всё как безобидная перестановка аннотаций.

Читается такой код сверху вниз с усилием: чтобы понять, чему равен times на пятом вызове, взгляд должен спуститься на два уровня замыканий. Опытный автор держит в голове, что параметры живут в замыкании навсегда, и что five из примера не изменится после импорта. Опытный читатель пробегает мимо, доверяясь тестам. Так элегантность одного автора превращается в скрытый налог на время каждого следующего.

Что копирует functools.wraps и какие имена остаются подделкой

Декоратор без wraps подменяет у функции всё: имя, документацию, аннотации, место в памяти. functools.wraps чинит это копированием. В стандартной библиотеке списки атрибутов заданы константами: WRAPPER_ASSIGNMENTS переносит в обёртку __module__, __name__, __qualname__, __annotations__, __type_params__ и __doc__, а WRAPPER_UPDATES дополняет словарь __dict__ оригиналом. Дополнительно wraps приписывает обёртке ссылку __wrapped__ на исходную функцию.

Проверка на месте:

import inspect

print(fetch_report.__name__)             # fetch_report
print(inspect.signature(fetch_report))   # (day: str) -> bytes

Подпись восстанавливается, потому что inspect.signature начиная с Python 3.4 умеет идти по цепочке __wrapped__. help() показывает документацию оригинала. Ссылка __wrapped__ полезна и без inspect: она даёт легальный обход обёртки, когда нужно вызвать оригинал напрямую, например чтобы обойти кэширующий декоратор при обслуживании инцидента:

raw = fetch_report.__wrapped__(day="2026-10-01")

На этом сходство кончается, и подделка становится видна: inspect.getsource(fetch_report) отдаст исходник обёртки, а не целевой функции, идентичность id() другая, кадр в трейсбеке всё равно называется wrapper. Обёртка это по-прежнему другая функция, просто в костюме оригинала. Для генераторов клиентского кода по сигнатурам, как это делает FastAPI при сборке OpenAPI-схемы, декоратор без wraps тихо превращает параметры эндпоинта в безымянные args и *kwargs, и документация API беднеет. Такая поломка не падает тестами, её находят пользователи.

Статические анализаторы разбирают обёртки без запуска кода, им нужен собственный мост. С Python 3.10 по PEP 612 доступен ParamSpec, позволяющий объявить фабрику, честно передающую аргументы и возвращаемый тип через обёртку:

from typing import Callable, ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")

def retry(times: int = 3) -> Callable[[Callable[P, R]], Callable[P, R]]:
    ...

Без ParamSpec проверяющий инструмент считает, что обёртка съела типы, и каждое обращение к декорированной функции подсвечивается предупреждением. Один импорт из typing экономит команде сотни ложных срабатываний.

Реальные трассировки стека через обёртки и адресация ошибок в Python 3.11

Каждый декорированный вызов добавляет в стек кадр обёртки. Реальный трейсбек из сервиса с повторами выглядит так:

Traceback (most recent call last):
  File "billing.py", line 31, in wrapper
    return func(*args, **kwargs)
  File "billing.py", line 88, in fetch_report
    payload = client.query(day)
ConnectionError: upstream refused

С Python 3.11 интерпретатор по PEP 657 указывает точную колонку в строке, и стрелки под выражением действительно помогают. Но имя кадра остаётся прежним. Если на функции стоят три декоратора, повторов, метрик и логирования, стек растёт на три кадра, из которых содержательный один. В трекерах инцидентов, группирующих ошибки по стеку, сотни разных функций с одинаковым кадром wrapper в одном модуле склеиваются в одну группу, и дежурный видит "одну частую ошибку" вместо десятка разных.

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

except Exception as exc:
    exc.__traceback__.tb_frame.f_locals  # уже и так в стеке
    raise RuntimeError(f"{func.__qualname__} failed") from exc

Отдельно болит этап импорта: ошибка в выражении внутри скобок, опечатка в имени настройки, нечисловое значение в int(), валит импорт всего модуля, а трейсбек показывает строку декоратора, а не реальную причину. Для длинных цепочек импортов такое падение читается плохо: сервис не стартует целиком, и бегать по кадрам приходится от конца к началу.

Грубовато, но группировка ошибок в трекере становится осмысленной, а дежурный видит имя функции, а не общую обёртку.

Класс вместо тройной вложенности и наблюдаемое состояние фабрики

Три уровня замыканий не единственный способ. Фабрика декоратора пишется классом, параметры становятся атрибутами, состояние политики видно тестам:

import functools
import time

class Retry:
    def __init__(self, times: int = 3, delay: float = 1.0):
        self.times = times
        self.delay = delay
        self.failed_attempts = 0

    def __call__(self, func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(self.times):
                try:
                    return func(*args, **kwargs)
                except Exception:
                    self.failed_attempts += 1
                    time.sleep(self.delay)
            raise
        wrapper.policy = self
        return wrapper

Класс читается плоско: у политики есть имя, конструктор и счётчики, IDE переходит по клику в __init__, а не в тень вложенной локальной функции. Метрики через policy снимают число неудачных попыток по всему сервису, тест читает состояние напрямую. Тесту теперь не нужно судить по задержкам, он читает политику напрямую:

def test_policy():
    assert sync_billing.policy.times == 3
    assert sync_billing.policy.failed_attempts == 0

Замыкания при всём этом не исчезают, wrapper внутри __call__ всё тот же, однако внешний уровень, главный источник путаницы при чтении, исчезает полностью. Для команд без гуру метапрограммирования класс почти всегда честнее: то же поведение, меньше скрытых подробностей.

Параметры декоратора вычисляются один раз при импорте модуля

Ловушка, которая находит почти каждого, кто пишет конфигурируемые обёртки:

import os

@retry(times=int(os.environ.get("RETRY_TIMES", "3")))
def sync_billing(day: str) -> bytes:
    ...

Выражение в скобках выполняется в момент импорта модуля, ровно один раз. Тест, подменяющий переменную окружения через monkeypatch, ничего не изменит, потому что чтение давно прошло. Смена настройки в удалённом конфиге требует перезапуска процесса. Самое коварное, что при локальном запуске всё работает: окружение разработчика совпадает с собой самим. В продакшене первая же попытка поднять другой RETRY_TIMES у одного из воркеров приводит к зоопарку поведений в одном флоте воркеров.

Лекарства два. Первый: параметры, которым предстоит меняться, передают коллбэком и вызывают его внутри wrapper на каждом выполнении:

@retry(times=lambda: settings.retry_times, delay=0.5)
def sync_billing(day: str) -> bytes:
    ...

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

Интроспекция, сериализация процессов и IDE рядом с обёрнутыми функциями

Обёртки не живут в изоляции, вокруг них инструменты. Сериализация процессов: multiprocessing и планировщики задач перекладывают callable между процессами через pickle, а локальная функция из замыкания не сериализуется, интерпретатор отвечает досадным Can't pickle local object. Декорированная на уровне модуля функция с обёрткой, собранной внутри фабрики, как раз такой локальной и является. Обходной путь один: обёртка на уровне модуля или класс с интерфейсом сериализации.

Отладчики и профилировщики честно показывают кадр wrapper и его исходник, глазами ищется целевая функция. Линтеры молчат: с их точки зрения всё корректно. Отсюда практический вывод для команды: сложность декораторов обязана компенсироваться их однообразием. Одна фабрика повторов на весь код, одна метрическая, одинаковые имена уровней. Разнобой в трёх самодельных retry с разными параметрами и разным порядком аргументов стоит дороже, чем любое замедление.

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

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

Чек-лист применимости декораторов с параметрами в командном коде

Решение об очередной фабрике обёрток проще принимать по короткому списку условий:

  1. без functools.wraps ни одна обёртка не попадает в общий модуль, а критичные сигнатуры проверяются тестом через inspect.signature;
  2. параметры, живущие в конфигурации, передаются коллбэками, значения в скобках декоратора считаются замороженными на импорте;
  3. каскад из трёх и более декораторов подряд переписывается в одну обёртку или явные вызовы, стек вызовов читается дежурным без документации;
  4. декорированные функции, уходящие в очереди задач и мультипроцессность, проверяются на сериализацию до релиза, а не после первого падения воркера;
  5. имя кадра в стеке сопровождается именем исходной функции, чтобы группировка ошибок в трекере не склеивала разные функции;
  6. для библиотечных обёрток с требованиями прозрачности берётся wrapt, самодельные прослойки не пишутся;
  7. декоратор остаётся технической прослойкой, переезд бизнес-правил внутрь обёртки тормозится сразу, правило это дешевле любого рефакторинга.

Метапрограммирование в Python сильное и потому соблазнительное. Декоратор с параметрами экономит строки, но тратит прозрачность: каждый следующий читатель платит временем за красоту первого автора. Хороший продакшн-код держит баланс: обёртки там, где они скрывают повторяющуюся рутину, явные вызовы там, где важен каждый кадр стека. Элегантность, за которую платят ночные дежурства, элегантностью не считается.