Skip to content

@IdempotencyStore.wrap incompatible with arg-projected methods (update_media_buy) #559

@bokelley

Description

@bokelley

What

@IdempotencyStore.wrap (src/adcp/server/idempotency/store.py:131-219) wraps the handler with signature (handler_self, params, context=None, *args, **kwargs). But the framework's arg-projector (src/adcp/decisioning/dispatch.py:~1080) splits some tools into per-field kwargs:

# dispatch.py:1085 (arg_projector branch for update_media_buy)
result = await method(**arg_projector, ctx=ctx)
# arg_projector = {"media_buy_id": "...", "patch": {...}}

When update_media_buy is decorated with @_IDEMPOTENCY.wrap, the wrap body never runs — Python raises before:

TypeError: MockSellerPlatform.update_media_buy() missing 1 required positional argument: 'params'

Strictly a calling-convention incompatibility. Dispatch passes media_buy_id=..., patch=..., ctx=... as kwargs. None match the params positional, no default, the projected kwargs land in **kwargs, and Python errors out before _wrapped's body executes. So this isn't a hash collision or cache-key issue — the wrap doesn't get a chance to compute anything.

Repro

salesagent's MockSellerPlatform.update_media_buy decorated with @_IDEMPOTENCY.wrap. Method signature is (self, media_buy_id, patch, ctx) per the framework's arg-projector contract. Calling through MCP storyboard runner against core/ raised the TypeError above on every update_media_buy request.

Reference: same shape as examples/v3_reference_seller/src/platform.py:735 which intentionally does NOT wrap update_media_buy (presumably for the same reason — neither the example nor the docs spell this out as a known incompatibility).

Workaround we shipped

Dropped @_IDEMPOTENCY.wrap from update_media_buy on both core/platforms/mock.py and core/platforms/gam.py. We've lost idempotency on update_media_buy — buyer retries re-execute side effects. Acceptable for the spike; not for prod.

What we'd want

Cleanest fix on the wrap side: make wrap signature-flexible so it works regardless of how the framework calls it. Detect the arg-projector calling pattern (kwargs that aren't ctx/context) and synthesize a params dict from those kwargs before hashing:

async def _wrapped(handler_self, params=None, context=None, *args, **kwargs):
    if params is None and kwargs:
        # Arg-projector calling pattern — synthesize params from non-ctx kwargs
        ctx_kw = kwargs.pop("ctx", None) or kwargs.pop("context", None)
        params = kwargs
        if ctx_kw is not None:
            context = ctx_kw
    # ... existing logic, then dispatch back via the same calling pattern
    return await handler(handler_self, **params, ctx=context)  # for arg-projected
    # OR: return await handler(handler_self, params, context, *args, **kwargs)  # for normal

(Sketch only — needs tightening on which calling pattern was used so the inner handler(...) invocation matches.)

Alternative on dispatch side: detect a wrapped handler and route via method(params=arg_projector_dict, ctx=ctx) instead of unpacking. But dispatch is generic and shouldn't know about @wrap, so the wrap-side fix is cleaner.

Filed by

salesagent kill-nginx/M2 spike — surfaced in core/SDK_FEEDBACK.md round 3 along with #558 (BearerTokenAuth × transport=both).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingclaude-triagedno-triageSkip the Claude triage bot — human or designated agent will handle this issue

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions