Skip to content

Commit 03da98a

Browse files
authored
fix(idempotency): recognize registered wrappers through outer decorators (#1315)
1 parent 07b5d04 commit 03da98a

2 files changed

Lines changed: 98 additions & 15 deletions

File tree

‎src/adcp/server/idempotency/store.py‎

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,29 @@ def _finish_supervised_operation(task: asyncio.Task[Any]) -> None:
8787

8888

8989
def is_wrapped(fn: Any) -> bool:
90-
"""Return True if ``fn`` was produced by :meth:`IdempotencyStore.wrap`.
90+
"""Return whether a callable's chain contains an SDK-registered wrapper.
9191
92-
Accepts bound methods (resolves to the underlying function before
93-
the membership check) and plain callables. Used by the boot-time
94-
validator at :mod:`adcp.decisioning.validate_idempotency`.
92+
Accepts bound methods and plain callables. Outer decorators must use
93+
``functools.wraps`` (or set ``__wrapped__``) so the registered wrapper
94+
remains reachable. Copied attributes alone do not establish membership.
95+
Used by :mod:`adcp.decisioning.validate_idempotency`.
9596
"""
96-
if fn is None:
97-
return False
98-
target = fn.__func__ if hasattr(fn, "__func__") else fn
99-
return target in _WRAPPED_FUNCTIONS
97+
seen: set[int] = set()
98+
# Bound the traversal to guard against pathological __wrapped__ cycles
99+
# and chains that manufacture a new callable on every attribute access.
100+
for _ in range(16):
101+
target = getattr(fn, "__func__", fn)
102+
if target is None or id(target) in seen:
103+
return False
104+
seen.add(id(target))
105+
try:
106+
if target in _WRAPPED_FUNCTIONS:
107+
return True
108+
except TypeError:
109+
# Unhashable/non-weak-referenceable objects cannot be registered.
110+
pass
111+
fn = getattr(target, "__wrapped__", None)
112+
return False
100113

101114

102115
# Spec bounds from capabilities.idempotency.replay_ttl_seconds (1h-7d).
@@ -364,13 +377,9 @@ def _log_late_failure(task: asyncio.Task[Any]) -> None:
364377
# registered, not the original handler: re-decorating a forked
365378
# copy of `handler` would otherwise falsely flag both.
366379
#
367-
# Contract for future maintainers: ``is_wrapped()`` checks
368-
# WeakSet membership of the closure object directly. Do NOT
369-
# change it to ``inspect.unwrap()``-then-check — the
370-
# ``@functools.wraps(handler)`` decorator above sets
371-
# ``_wrapped.__wrapped__ = handler``, so ``inspect.unwrap``
372-
# would return the original handler (not in the WeakSet) and
373-
# the validator would silently regress.
380+
# ``is_wrapped`` checks registry membership at each __wrapped__ link.
381+
# Full inspect.unwrap()-then-check would reach the original handler,
382+
# which is not registered, and miss this closure under outer decorators.
374383
_WRAPPED_FUNCTIONS.add(_wrapped)
375384
return _wrapped
376385

‎tests/test_validate_idempotency_wiring.py‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77

88
from __future__ import annotations
99

10+
import functools
11+
from types import SimpleNamespace
12+
1013
import pytest
1114

1215
from adcp.decisioning import DecisioningCapabilities, DecisioningPlatform, SingletonAccounts
@@ -60,6 +63,77 @@ async def handler(self, params, context=None):
6063

6164
assert is_wrapped(handler) is True
6265

66+
@pytest.mark.parametrize("depth", [1, 2, 15])
67+
def test_outer_decorators_and_bound_methods(self, depth: int) -> None:
68+
store = _store()
69+
70+
@store.wrap
71+
async def handler(self, params, context=None):
72+
return {}
73+
74+
for _ in range(depth):
75+
inner = handler
76+
77+
@functools.wraps(inner)
78+
async def outer(*args, _inner=inner, **kwargs):
79+
return await _inner(*args, **kwargs)
80+
81+
handler = outer
82+
83+
class Seller:
84+
capabilities = _caps_with_idempotency(supported=True)
85+
create_media_buy = handler
86+
87+
assert is_wrapped(handler)
88+
assert is_wrapped(Seller().create_media_buy)
89+
validate_idempotency_wiring(Seller())
90+
91+
def test_mid_chain_bound_method(self) -> None:
92+
store = _store()
93+
94+
class Seller:
95+
@store.wrap
96+
async def create_media_buy(self, params, context=None):
97+
return {}
98+
99+
@functools.wraps(Seller().create_media_buy)
100+
async def outer(*args, **kwargs):
101+
return {}
102+
103+
assert is_wrapped(outer)
104+
105+
def test_outer_without_wrapped_link_is_not_registered(self) -> None:
106+
store = _store()
107+
108+
@store.wrap
109+
async def handler(self, params, context=None):
110+
return {}
111+
112+
async def outer(*args, **kwargs):
113+
return await handler(*args, **kwargs)
114+
115+
outer.__dict__.update(handler.__dict__)
116+
outer.__dict__.pop("__wrapped__", None)
117+
assert not is_wrapped(outer)
118+
119+
def test_cycle_and_depth_limit(self) -> None:
120+
async def cycle():
121+
return {}
122+
123+
cycle.__wrapped__ = cycle
124+
assert not is_wrapped(cycle)
125+
other = SimpleNamespace(__wrapped__=cycle)
126+
cycle.__wrapped__ = other
127+
assert not is_wrapped(cycle)
128+
target = _store().wrap(cycle)
129+
for _ in range(16):
130+
target = SimpleNamespace(__wrapped__=target)
131+
assert not is_wrapped(target)
132+
133+
@pytest.mark.parametrize("value", [[], {}, 42, object()])
134+
def test_non_callable_values_are_not_registered(self, value) -> None:
135+
assert not is_wrapped(value)
136+
63137
def test_none_returns_false(self) -> None:
64138
assert is_wrapped(None) is False
65139

0 commit comments

Comments
 (0)