Ночной алерт приносит трейсбек из шести кадров, и четыре из них называются одинаково: 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: библиотека строит прозрачные обёртки, сохраняющие подпись, интроспекцию и сериализуемость, и используется самими фреймворками для инструментария. Писать вручную прослойку такого уровня не стоит ни одной команде.
Чек-лист применимости декораторов с параметрами в командном коде
Решение об очередной фабрике обёрток проще принимать по короткому списку условий:
- без functools.wraps ни одна обёртка не попадает в общий модуль, а критичные сигнатуры проверяются тестом через inspect.signature;
- параметры, живущие в конфигурации, передаются коллбэками, значения в скобках декоратора считаются замороженными на импорте;
- каскад из трёх и более декораторов подряд переписывается в одну обёртку или явные вызовы, стек вызовов читается дежурным без документации;
- декорированные функции, уходящие в очереди задач и мультипроцессность, проверяются на сериализацию до релиза, а не после первого падения воркера;
- имя кадра в стеке сопровождается именем исходной функции, чтобы группировка ошибок в трекере не склеивала разные функции;
- для библиотечных обёрток с требованиями прозрачности берётся wrapt, самодельные прослойки не пишутся;
- декоратор остаётся технической прослойкой, переезд бизнес-правил внутрь обёртки тормозится сразу, правило это дешевле любого рефакторинга.
Метапрограммирование в Python сильное и потому соблазнительное. Декоратор с параметрами экономит строки, но тратит прозрачность: каждый следующий читатель платит временем за красоту первого автора. Хороший продакшн-код держит баланс: обёртки там, где они скрывают повторяющуюся рутину, явные вызовы там, где важен каждый кадр стека. Элегантность, за которую платят ночные дежурства, элегантностью не считается.