|
| 1 | +"""Boot-time validator: declared idempotency capability vs. wired decorator. |
| 2 | +
|
| 3 | +Catches the silent-lie configuration: a platform that advertises |
| 4 | +``capabilities.adcp.idempotency.supported=True`` on the AdCP |
| 5 | +``get_adcp_capabilities`` response while never applying |
| 6 | +:meth:`adcp.server.idempotency.IdempotencyStore.wrap` to any handler |
| 7 | +method. Buyers reading the capabilities envelope assume retries are |
| 8 | +deduped; without the decorator, every retry re-executes side effects. |
| 9 | +
|
| 10 | +The check is loose by design — "any wrapped method on the platform" |
| 11 | +passes. A platform that wraps ``create_media_buy`` but forgets |
| 12 | +``update_media_buy`` slips through here. Tightening to per-method |
| 13 | +coverage requires the spec to expose a canonical mutating-tool enum, |
| 14 | +which AdCP #2315 doesn't yet. The loose check still catches the |
| 15 | +dominant failure mode (capability declared, decorator never applied). |
| 16 | +
|
| 17 | +**Escape hatch.** Adopters who terminate idempotency upstream of the |
| 18 | +SDK — gateway-tier dedup (Kong, Envoy, ASGI middleware), or a |
| 19 | +bring-your-own decorator that doesn't go through |
| 20 | +:meth:`IdempotencyStore.wrap` — set ``_adcp_idempotency_external = True`` |
| 21 | +on the platform class to opt out of this check. They still publish |
| 22 | +``IdempotencySupported`` to buyers; the validator just trusts that the |
| 23 | +dedup is wired somewhere the SDK can't see. |
| 24 | +
|
| 25 | +Mirrors :func:`adcp.decisioning.property_list.validate_property_list_config` |
| 26 | +and :func:`adcp.decisioning.webhook_emit.validate_webhook_sender_for_platform` — |
| 27 | +boot-time fail-fast with a structured :class:`AdcpError`. |
| 28 | +""" |
| 29 | + |
| 30 | +from __future__ import annotations |
| 31 | + |
| 32 | +from typing import Any |
| 33 | + |
| 34 | +from adcp.server.idempotency import is_wrapped |
| 35 | + |
| 36 | + |
| 37 | +def idempotency_capability_supported(platform: Any) -> bool: |
| 38 | + """Return True if ``platform.capabilities.adcp.idempotency.supported`` is True. |
| 39 | +
|
| 40 | + Walks the three-level attribute chain defensively — adopters may set |
| 41 | + capabilities to ``None`` or omit nested fields entirely. |
| 42 | + """ |
| 43 | + caps = getattr(platform, "capabilities", None) |
| 44 | + if caps is None: |
| 45 | + return False |
| 46 | + adcp = getattr(caps, "adcp", None) |
| 47 | + if adcp is None: |
| 48 | + return False |
| 49 | + idempotency = getattr(adcp, "idempotency", None) |
| 50 | + if idempotency is None: |
| 51 | + return False |
| 52 | + return getattr(idempotency, "supported", None) is True |
| 53 | + |
| 54 | + |
| 55 | +def _candidate_method_names(platform: Any) -> list[str]: |
| 56 | + """Return public method names on the platform. |
| 57 | +
|
| 58 | + Uses ``dir()`` + per-name ``getattr`` with try/except so a |
| 59 | + boot-side-effect property (DB lookup, config fetch) on the platform |
| 60 | + class doesn't blow up the validator. ``inspect.getmembers`` would |
| 61 | + fire every descriptor. |
| 62 | + """ |
| 63 | + out: list[str] = [] |
| 64 | + for name in dir(platform): |
| 65 | + if name.startswith("_"): |
| 66 | + continue |
| 67 | + try: |
| 68 | + attr = getattr(platform, name) |
| 69 | + except Exception: # noqa: BLE001 — descriptor side effects are out of our control |
| 70 | + continue |
| 71 | + if callable(attr): |
| 72 | + out.append(name) |
| 73 | + return out |
| 74 | + |
| 75 | + |
| 76 | +def _platform_has_wrapped_method(platform: Any) -> bool: |
| 77 | + """True if any callable on the platform is registered as wrapped. |
| 78 | +
|
| 79 | + Walks instance attributes so adopters who bind a wrapped function |
| 80 | + in ``__init__`` (``self.create_media_buy = wrapped_fn``) are |
| 81 | + recognized too — not just class-level ``@`` decoration. |
| 82 | + """ |
| 83 | + for name in dir(platform): |
| 84 | + if name.startswith("_"): |
| 85 | + continue |
| 86 | + try: |
| 87 | + attr = getattr(platform, name) |
| 88 | + except Exception: # noqa: BLE001 |
| 89 | + continue |
| 90 | + if not callable(attr): |
| 91 | + continue |
| 92 | + if is_wrapped(attr): |
| 93 | + return True |
| 94 | + return False |
| 95 | + |
| 96 | + |
| 97 | +def validate_idempotency_wiring(platform: Any) -> None: |
| 98 | + """Boot-time fail-fast: idempotency advertised but no method wrapped. |
| 99 | +
|
| 100 | + Honors the ``_adcp_idempotency_external = True`` opt-out for |
| 101 | + adopters with upstream gateway dedup or a BYO decorator the SDK |
| 102 | + can't introspect. |
| 103 | +
|
| 104 | + :raises AdcpError: ``recovery='terminal'`` when the platform |
| 105 | + declares ``IdempotencySupported(supported=True)`` but no method |
| 106 | + on the platform is decorated with |
| 107 | + :meth:`adcp.server.idempotency.IdempotencyStore.wrap` AND the |
| 108 | + external opt-out is not set. |
| 109 | + """ |
| 110 | + if not idempotency_capability_supported(platform): |
| 111 | + return |
| 112 | + if getattr(platform, "_adcp_idempotency_external", False): |
| 113 | + return |
| 114 | + if _platform_has_wrapped_method(platform): |
| 115 | + return |
| 116 | + |
| 117 | + from adcp.decisioning.types import AdcpError |
| 118 | + |
| 119 | + raise AdcpError( |
| 120 | + "INVALID_REQUEST", |
| 121 | + message=( |
| 122 | + "capabilities.adcp.idempotency.supported=True is declared but " |
| 123 | + "no method on the platform is decorated with @IdempotencyStore.wrap. " |
| 124 | + "Buyers reading the capabilities envelope expect retries to be " |
| 125 | + "deduped; without the decorator every retry re-executes side " |
| 126 | + "effects. Wrap your mutating handlers — typically " |
| 127 | + "create_media_buy, update_media_buy, sync_creatives, " |
| 128 | + "activate_signal — with the decorator:\n\n" |
| 129 | + " from adcp.server.idempotency import IdempotencyStore, MemoryBackend\n" |
| 130 | + " idempotency = IdempotencyStore(backend=MemoryBackend(), ttl_seconds=86400)\n\n" |
| 131 | + " class MySeller(DecisioningPlatform):\n" |
| 132 | + " @idempotency.wrap\n" |
| 133 | + " async def create_media_buy(self, params, context=None):\n" |
| 134 | + " ...\n\n" |
| 135 | + "Alternatively: set IdempotencySupported(supported=False) to opt " |
| 136 | + "out, OR — if dedup is wired upstream of the SDK (gateway tier, " |
| 137 | + "BYO middleware) — set _adcp_idempotency_external = True on the " |
| 138 | + "platform class." |
| 139 | + ), |
| 140 | + recovery="terminal", |
| 141 | + details={ |
| 142 | + "missing": "@IdempotencyStore.wrap", |
| 143 | + "decorator_import": "from adcp.server.idempotency import IdempotencyStore", |
| 144 | + "candidate_methods": _candidate_method_names(platform), |
| 145 | + "opt_out": "IdempotencySupported(supported=False)", |
| 146 | + "external_opt_out": "_adcp_idempotency_external = True", |
| 147 | + }, |
| 148 | + ) |
| 149 | + |
| 150 | + |
| 151 | +__all__ = [ |
| 152 | + "idempotency_capability_supported", |
| 153 | + "validate_idempotency_wiring", |
| 154 | +] |
0 commit comments