Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions examples/multi_platform_seller/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
from adcp.decisioning.capabilities import Account as CapabilitiesAccount
from adcp.decisioning.capabilities import (
Adcp,
IdempotencySupported,
IdempotencyUnsupported,
MediaBuy,
SupportedProtocol,
)
Expand Down Expand Up @@ -76,7 +76,11 @@ def build_router() -> PlatformRouter:
specialisms=["sales-guaranteed", "sales-non-guaranteed"],
adcp=Adcp(
major_versions=[3],
idempotency=IdempotencySupported(supported=True, replay_ttl_seconds=86400),
# Router union over two mock platforms — neither wires
# in-memory dedup, so the union honestly advertises
# unsupported. Real adopters wrap mutating handlers with
# @IdempotencyStore.wrap and declare supported=True.
idempotency=IdempotencyUnsupported(supported=False),
),
account=CapabilitiesAccount(supported_billing=["operator"]),
media_buy=MediaBuy(supported_pricing_models=["cpm"]),
Expand Down
9 changes: 7 additions & 2 deletions examples/multi_platform_seller/src/mock_guaranteed.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from adcp.decisioning.capabilities import Account as CapabilitiesAccount
from adcp.decisioning.capabilities import (
Adcp,
IdempotencySupported,
IdempotencyUnsupported,
MediaBuy,
SupportedProtocol,
)
Expand Down Expand Up @@ -131,7 +131,12 @@ class MockGuaranteedPlatform(DecisioningPlatform, SalesPlatform):
specialisms=["sales-guaranteed"],
adcp=Adcp(
major_versions=[3],
idempotency=IdempotencySupported(supported=True, replay_ttl_seconds=86400),
# Mock platform: no in-memory dedup wired. Honest declaration
# over a silent-lie supported=True (the SDK's boot-time
# validator at adcp.decisioning.validate_idempotency catches
# the latter). Real adopters wrap mutating handlers with
# @IdempotencyStore.wrap and declare supported=True.
idempotency=IdempotencyUnsupported(supported=False),
),
account=CapabilitiesAccount(supported_billing=["operator"]),
media_buy=MediaBuy(supported_pricing_models=["cpm"]),
Expand Down
9 changes: 7 additions & 2 deletions examples/multi_platform_seller/src/mock_non_guaranteed.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from adcp.decisioning.capabilities import Account as CapabilitiesAccount
from adcp.decisioning.capabilities import (
Adcp,
IdempotencySupported,
IdempotencyUnsupported,
MediaBuy,
SupportedProtocol,
)
Expand Down Expand Up @@ -118,7 +118,12 @@ class MockNonGuaranteedPlatform(DecisioningPlatform, SalesPlatform):
specialisms=["sales-non-guaranteed"],
adcp=Adcp(
major_versions=[3],
idempotency=IdempotencySupported(supported=True, replay_ttl_seconds=86400),
# Mock platform: no in-memory dedup wired. Honest declaration
# over a silent-lie supported=True (the SDK's boot-time
# validator at adcp.decisioning.validate_idempotency catches
# the latter). Real adopters wrap mutating handlers with
# @IdempotencyStore.wrap and declare supported=True.
idempotency=IdempotencyUnsupported(supported=False),
),
account=CapabilitiesAccount(supported_billing=["operator"]),
media_buy=MediaBuy(supported_pricing_models=["cpm"]),
Expand Down
10 changes: 10 additions & 0 deletions src/adcp/decisioning/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,16 @@ def create_adcp_server_from_platform(

validate_capabilities_response_shape(handler)

# Boot-time fail-fast: idempotency advertised but no @wrap applied.
# Buyers reading IdempotencySupported(supported=True) on the
# capabilities envelope assume retries dedupe; without the
# decorator, every retry re-executes side effects.
from adcp.decisioning.validate_idempotency import (
validate_idempotency_wiring,
)

validate_idempotency_wiring(platform)

return handler, executor, registry


Expand Down
154 changes: 154 additions & 0 deletions src/adcp/decisioning/validate_idempotency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""Boot-time validator: declared idempotency capability vs. wired decorator.

Catches the silent-lie configuration: a platform that advertises
``capabilities.adcp.idempotency.supported=True`` on the AdCP
``get_adcp_capabilities`` response while never applying
:meth:`adcp.server.idempotency.IdempotencyStore.wrap` to any handler
method. Buyers reading the capabilities envelope assume retries are
deduped; without the decorator, every retry re-executes side effects.

The check is loose by design — "any wrapped method on the platform"
passes. A platform that wraps ``create_media_buy`` but forgets
``update_media_buy`` slips through here. Tightening to per-method
coverage requires the spec to expose a canonical mutating-tool enum,
which AdCP #2315 doesn't yet. The loose check still catches the
dominant failure mode (capability declared, decorator never applied).

**Escape hatch.** Adopters who terminate idempotency upstream of the
SDK — gateway-tier dedup (Kong, Envoy, ASGI middleware), or a
bring-your-own decorator that doesn't go through
:meth:`IdempotencyStore.wrap` — set ``_adcp_idempotency_external = True``
on the platform class to opt out of this check. They still publish
``IdempotencySupported`` to buyers; the validator just trusts that the
dedup is wired somewhere the SDK can't see.

Mirrors :func:`adcp.decisioning.property_list.validate_property_list_config`
and :func:`adcp.decisioning.webhook_emit.validate_webhook_sender_for_platform` —
boot-time fail-fast with a structured :class:`AdcpError`.
"""

from __future__ import annotations

from typing import Any

from adcp.server.idempotency import is_wrapped


def idempotency_capability_supported(platform: Any) -> bool:
"""Return True if ``platform.capabilities.adcp.idempotency.supported`` is True.

Walks the three-level attribute chain defensively — adopters may set
capabilities to ``None`` or omit nested fields entirely.
"""
caps = getattr(platform, "capabilities", None)
if caps is None:
return False
adcp = getattr(caps, "adcp", None)
if adcp is None:
return False
idempotency = getattr(adcp, "idempotency", None)
if idempotency is None:
return False
return getattr(idempotency, "supported", None) is True


def _candidate_method_names(platform: Any) -> list[str]:
"""Return public method names on the platform.

Uses ``dir()`` + per-name ``getattr`` with try/except so a
boot-side-effect property (DB lookup, config fetch) on the platform
class doesn't blow up the validator. ``inspect.getmembers`` would
fire every descriptor.
"""
out: list[str] = []
for name in dir(platform):
if name.startswith("_"):
continue
try:
attr = getattr(platform, name)
except Exception: # noqa: BLE001 — descriptor side effects are out of our control
continue
if callable(attr):
out.append(name)
return out


def _platform_has_wrapped_method(platform: Any) -> bool:
"""True if any callable on the platform is registered as wrapped.

Walks instance attributes so adopters who bind a wrapped function
in ``__init__`` (``self.create_media_buy = wrapped_fn``) are
recognized too — not just class-level ``@`` decoration.
"""
for name in dir(platform):
if name.startswith("_"):
continue
try:
attr = getattr(platform, name)
except Exception: # noqa: BLE001
continue
if not callable(attr):
continue
if is_wrapped(attr):
return True
return False


def validate_idempotency_wiring(platform: Any) -> None:
"""Boot-time fail-fast: idempotency advertised but no method wrapped.

Honors the ``_adcp_idempotency_external = True`` opt-out for
adopters with upstream gateway dedup or a BYO decorator the SDK
can't introspect.

:raises AdcpError: ``recovery='terminal'`` when the platform
declares ``IdempotencySupported(supported=True)`` but no method
on the platform is decorated with
:meth:`adcp.server.idempotency.IdempotencyStore.wrap` AND the
external opt-out is not set.
"""
if not idempotency_capability_supported(platform):
return
if getattr(platform, "_adcp_idempotency_external", False):
return
if _platform_has_wrapped_method(platform):
return

from adcp.decisioning.types import AdcpError

raise AdcpError(
"INVALID_REQUEST",
message=(
"capabilities.adcp.idempotency.supported=True is declared but "
"no method on the platform is decorated with @IdempotencyStore.wrap. "
"Buyers reading the capabilities envelope expect retries to be "
"deduped; without the decorator every retry re-executes side "
"effects. Wrap your mutating handlers — typically "
"create_media_buy, update_media_buy, sync_creatives, "
"activate_signal — with the decorator:\n\n"
" from adcp.server.idempotency import IdempotencyStore, MemoryBackend\n"
" idempotency = IdempotencyStore(backend=MemoryBackend(), ttl_seconds=86400)\n\n"
" class MySeller(DecisioningPlatform):\n"
" @idempotency.wrap\n"
" async def create_media_buy(self, params, context=None):\n"
" ...\n\n"
"Alternatively: set IdempotencySupported(supported=False) to opt "
"out, OR — if dedup is wired upstream of the SDK (gateway tier, "
"BYO middleware) — set _adcp_idempotency_external = True on the "
"platform class."
),
recovery="terminal",
details={
"missing": "@IdempotencyStore.wrap",
"decorator_import": "from adcp.server.idempotency import IdempotencyStore",
"candidate_methods": _candidate_method_names(platform),
"opt_out": "IdempotencySupported(supported=False)",
"external_opt_out": "_adcp_idempotency_external = True",
},
)


__all__ = [
"idempotency_capability_supported",
"validate_idempotency_wiring",
]
3 changes: 2 additions & 1 deletion src/adcp/server/idempotency/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ async def get_adcp_capabilities(self, params, context=None):
canonical_json_sha256,
strip_excluded_fields,
)
from adcp.server.idempotency.store import IdempotencyStore
from adcp.server.idempotency.store import IdempotencyStore, is_wrapped
from adcp.server.idempotency.webhook_dedup import WebhookDedupStore

__all__ = [
Expand All @@ -75,5 +75,6 @@ async def get_adcp_capabilities(self, params, context=None):
"PgBackend",
"WebhookDedupStore",
"canonical_json_sha256",
"is_wrapped",
"strip_excluded_fields",
]
43 changes: 43 additions & 0 deletions src/adcp/server/idempotency/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import logging
import time
import warnings
import weakref
from collections.abc import Awaitable, Callable
from functools import wraps
from typing import Any
Expand All @@ -44,6 +45,33 @@

logger = logging.getLogger(__name__)

# Registry of functions returned by IdempotencyStore.wrap. Read by
# adcp.decisioning.validate_idempotency.is_wrapped() to reconcile the
# adopter's declared IdempotencySupported capability against actual
# decorator application. WeakSet so wrapper functions garbage-collect
# normally when the platform method holding them goes away — the
# registry doesn't pin them in memory.
#
# Defense-in-depth choice over a public attribute on the wrapper: a
# plain attr can be set by any caller (test fixture, monkeypatch) and
# silently defeat the validator. Membership in this private set is
# only granted by IdempotencyStore.wrap itself.
_WRAPPED_FUNCTIONS: weakref.WeakSet[Callable[..., Any]] = weakref.WeakSet()


def is_wrapped(fn: Any) -> bool:
"""Return True if ``fn`` was produced by :meth:`IdempotencyStore.wrap`.

Accepts bound methods (resolves to the underlying function before
the membership check) and plain callables. Used by the boot-time
validator at :mod:`adcp.decisioning.validate_idempotency`.
"""
if fn is None:
return False
target = fn.__func__ if hasattr(fn, "__func__") else fn
return target in _WRAPPED_FUNCTIONS


# Spec bounds from capabilities.idempotency.replay_ttl_seconds (1h-7d).
_MIN_TTL_SECONDS = 3600
_MAX_TTL_SECONDS = 604800
Expand Down Expand Up @@ -189,6 +217,21 @@ async def _wrapped(
)
return response

# Register the wrapper for the boot-time validator at
# adcp.decisioning.validate_idempotency. WeakSet membership —
# not a public attribute — so adopters can't spoof "wrapped"
# by stamping an attr on a plain function. The wrapper is
# registered, not the original handler: re-decorating a forked
# copy of `handler` would otherwise falsely flag both.
#
# Contract for future maintainers: ``is_wrapped()`` checks
# WeakSet membership of the closure object directly. Do NOT
# change it to ``inspect.unwrap()``-then-check — the
# ``@functools.wraps(handler)`` decorator above sets
# ``_wrapped.__wrapped__ = handler``, so ``inspect.unwrap``
# would return the original handler (not in the WeakSet) and
# the validator would silently regress.
_WRAPPED_FUNCTIONS.add(_wrapped)
return _wrapped

def _prepare(self, params: Any, context: Any) -> tuple[str | None, str | None, dict[str, Any]]:
Expand Down
Loading
Loading