@@ -48,7 +48,7 @@ <h2 class="section-title" id="header-classes">Classes</h2>
4848< dl >
4949< dt id ="adcp.client.ADCPClient "> < code class ="flex name class ">
5050< span > class < span class ="ident "> ADCPClient</ span > </ span >
51- < span > (</ span > < span > agent_config: AgentConfig,< br > webhook_url_template: str | None = None,< br > webhook_secret: str | None = None,< br > on_activity: Callable[[Activity], None] | None = None,< br > webhook_timestamp_tolerance: int = 300,< br > capabilities_ttl: float = 3600.0,< br > validate_features: bool = False)</ span >
51+ < span > (</ span > < span > agent_config: AgentConfig,< br > webhook_url_template: str | None = None,< br > webhook_secret: str | None = None,< br > on_activity: Callable[[Activity], None] | None = None,< br > webhook_timestamp_tolerance: int = 300,< br > capabilities_ttl: float = 3600.0,< br > validate_features: bool = False, < br > strict_idempotency: bool = False )</ span >
5252</ code > </ dt >
5353< dd >
5454< details class ="source ">
@@ -67,6 +67,7 @@ <h2 class="section-title" id="header-classes">Classes</h2>
6767 webhook_timestamp_tolerance: int = 300,
6868 capabilities_ttl: float = 3600.0,
6969 validate_features: bool = False,
70+ strict_idempotency: bool = False,
7071 ):
7172 """
7273 Initialize ADCP client for a single agent.
@@ -84,6 +85,12 @@ <h2 class="section-title" id="header-classes">Classes</h2>
8485 validate_features: When True, automatically check that the seller supports
8586 required features before making task calls (e.g., sync_audiences requires
8687 audience_targeting). Requires capabilities to have been fetched first.
88+ strict_idempotency: When True, verify the seller declared
89+ ``adcp.idempotency.replay_ttl_seconds`` in capabilities before any
90+ mutating call. Fetches capabilities lazily on first use. Raises
91+ ``IdempotencyUnsupportedError`` if the declaration is missing —
92+ sellers that don't declare it provide no retry-safety guarantee
93+ per AdCP #2315. Defaults to False for backward compatibility.
8794 """
8895 self.agent_config = agent_config
8996 self.webhook_url_template = webhook_url_template
@@ -92,11 +99,18 @@ <h2 class="section-title" id="header-classes">Classes</h2>
9299 self.webhook_timestamp_tolerance = webhook_timestamp_tolerance
93100 self.capabilities_ttl = capabilities_ttl
94101 self.validate_features = validate_features
102+ self.strict_idempotency = strict_idempotency
95103
96104 # Capabilities cache
97105 self._capabilities: GetAdcpCapabilitiesResponse | None = None
98106 self._feature_resolver: FeatureResolver | None = None
99107 self._capabilities_fetched_at: float | None = None
108+ self._idempotency_capability_verified: bool = False
109+ # Unique per-instance token so use_idempotency_key scopes to this
110+ # client and does not bleed to siblings (AdCP #2315 cross-seller risk).
111+ from uuid import uuid4 as _uuid4
112+
113+ self._idempotency_client_token: str = _uuid4().hex
100114
101115 # Initialize protocol adapter
102116 self.adapter: ProtocolAdapter
@@ -107,11 +121,52 @@ <h2 class="section-title" id="header-classes">Classes</h2>
107121 else:
108122 raise ValueError(f"Unsupported protocol: {agent_config.protocol}")
109123
124+ self.adapter.idempotency_client_token = self._idempotency_client_token
125+ if strict_idempotency:
126+ self.adapter.idempotency_capability_check = self._ensure_idempotency_capability
127+
110128 # Initialize simple API accessor (lazy import to avoid circular dependency)
111129 from adcp.simple import SimpleAPI
112130
113131 self.simple = SimpleAPI(self)
114132
133+ async def _ensure_idempotency_capability(self) -> None:
134+ """Verify the seller declared idempotency.replay_ttl_seconds in capabilities.
135+
136+ Called before every mutating request when ``strict_idempotency=True``.
137+ Fetches capabilities on first invocation; subsequent calls are no-ops
138+ once the declaration has been observed. Raises
139+ ``IdempotencyUnsupportedError`` when the seller is missing the field.
140+
141+ Sets ``_idempotency_capability_verified = True`` BEFORE calling
142+ ``fetch_capabilities`` so any recursive dispatch through the adapter
143+ terminates (``get_adcp_capabilities`` is non-mutating, so it would
144+ short-circuit anyway — but this guard protects against future refactors
145+ that might add it to the mutating set).
146+ """
147+ from adcp.exceptions import IdempotencyUnsupportedError
148+
149+ if self._idempotency_capability_verified:
150+ return
151+
152+ self._idempotency_capability_verified = True
153+ try:
154+ caps = await self.fetch_capabilities()
155+ adcp_info = getattr(caps, "adcp", None)
156+ idempotency_info = getattr(adcp_info, "idempotency", None) if adcp_info else None
157+ ttl = (
158+ getattr(idempotency_info, "replay_ttl_seconds", None) if idempotency_info else None
159+ )
160+
161+ if ttl is None:
162+ raise IdempotencyUnsupportedError(
163+ agent_id=self.agent_config.id,
164+ agent_uri=self.agent_config.agent_uri,
165+ )
166+ except Exception:
167+ self._idempotency_capability_verified = False
168+ raise
169+
115170 def get_webhook_url(self, task_type: str, operation_id: str) -> str:
116171 """Generate webhook URL for a task."""
117172 if not self.webhook_url_template:
@@ -128,6 +183,50 @@ <h2 class="section-title" id="header-classes">Classes</h2>
128183 if self.on_activity:
129184 self.on_activity(activity)
130185
186+ @contextlib.contextmanager
187+ def use_idempotency_key(self, key: str) -> Iterator[str]:
188+ """Pin an ``idempotency_key`` for the next mutating call on THIS client.
189+
190+ Use when you've persisted a key (e.g., in a buyer-side database) and
191+ want the SDK to send that exact key on resume or retry across process
192+ restarts. The key is validated against ``^[A-Za-z0-9_.:-]{16,255}$`` on
193+ entry; a ``ValueError`` is raised for malformed keys.
194+
195+ Scope rules:
196+
197+ * **Single-use within scope.** The first mutating call inside the
198+ ``with`` block consumes the pinned key; a second mutating call falls
199+ through to a fresh UUID. This protects against ``asyncio.gather``
200+ siblings accidentally sharing the key (which would trigger
201+ ``IDEMPOTENCY_CONFLICT`` or silently duplicate work). If you need to
202+ retry, wrap each attempt in its own ``with`` block.
203+ * **Client-scoped.** The pinned key applies only to calls on THIS
204+ client. A mutating call on a sibling ``ADCPClient`` inside the same
205+ ``with`` block generates a fresh key and emits a ``UserWarning`` —
206+ keys must be unique per (seller, request) pair (AdCP #2315).
207+ * **No nesting.** Nested ``use_idempotency_key`` on the same client
208+ raises ``RuntimeError``.
209+
210+ Example::
211+
212+ with client.use_idempotency_key(campaign.stored_key):
213+ result = await client.create_media_buy(request)
214+ """
215+ from adcp import _idempotency
216+
217+ _idempotency.validate_key(key)
218+ token = self._idempotency_client_token
219+ if token in _idempotency._scoped_keys:
220+ raise RuntimeError(
221+ "use_idempotency_key is already active on this client; "
222+ "nested usage is not supported."
223+ )
224+ _idempotency._scoped_keys[token] = key
225+ try:
226+ yield key
227+ finally:
228+ _idempotency._scoped_keys.pop(token, None)
229+
131230 # ========================================================================
132231 # Capability Validation
133232 # ========================================================================
@@ -2789,9 +2888,7 @@ <h2 class="section-title" id="header-classes">Classes</h2>
27892888 True if signature is valid, False otherwise
27902889 """
27912890 if not self.webhook_secret:
2792- logger.warning(
2793- "Webhook signature verification skipped: no webhook_secret configured"
2794- )
2891+ logger.warning("Webhook signature verification skipped: no webhook_secret configured")
27952892 return True
27962893
27972894 # Reject stale or future timestamps to prevent replay attacks
@@ -3313,6 +3410,13 @@ <h2 id="args">Args</h2>
33133410< dd > When True, automatically check that the seller supports
33143411required features before making task calls (e.g., sync_audiences requires
33153412audience_targeting). Requires capabilities to have been fetched first.</ dd >
3413+ < dt > < strong > < code > strict_idempotency</ code > </ strong > </ dt >
3414+ < dd > When True, verify the seller declared
3415+ < code > adcp.idempotency.replay_ttl_seconds</ code > in capabilities before any
3416+ mutating call. Fetches capabilities lazily on first use. Raises
3417+ < code > IdempotencyUnsupportedError</ code > if the declaration is missing —
3418+ sellers that don't declare it provide no retry-safety guarantee
3419+ per AdCP #2315. Defaults to False for backward compatibility.</ dd >
33163420</ dl > </ div >
33173421< h3 > Instance variables</ h3 >
33183422< dl >
@@ -7219,6 +7323,83 @@ <h2 id="args">Args</h2>
72197323< h2 id ="returns "> Returns</ h2 >
72207324< p > TaskResult containing UpdatePropertyListResponse</ p > </ div >
72217325</ dd >
7326+ < dt id ="adcp.client.ADCPClient.use_idempotency_key "> < code class ="name flex ">
7327+ < span > def < span class ="ident "> use_idempotency_key</ span > </ span > (< span > self, key: str) ‑> Iterator[str]</ span >
7328+ </ code > </ dt >
7329+ < dd >
7330+ < details class ="source ">
7331+ < summary >
7332+ < span > Expand source code</ span >
7333+ </ summary >
7334+ < pre > < code class ="python "> @contextlib.contextmanager
7335+ def use_idempotency_key(self, key: str) -> Iterator[str]:
7336+ """Pin an ``idempotency_key`` for the next mutating call on THIS client.
7337+
7338+ Use when you've persisted a key (e.g., in a buyer-side database) and
7339+ want the SDK to send that exact key on resume or retry across process
7340+ restarts. The key is validated against ``^[A-Za-z0-9_.:-]{16,255}$`` on
7341+ entry; a ``ValueError`` is raised for malformed keys.
7342+
7343+ Scope rules:
7344+
7345+ * **Single-use within scope.** The first mutating call inside the
7346+ ``with`` block consumes the pinned key; a second mutating call falls
7347+ through to a fresh UUID. This protects against ``asyncio.gather``
7348+ siblings accidentally sharing the key (which would trigger
7349+ ``IDEMPOTENCY_CONFLICT`` or silently duplicate work). If you need to
7350+ retry, wrap each attempt in its own ``with`` block.
7351+ * **Client-scoped.** The pinned key applies only to calls on THIS
7352+ client. A mutating call on a sibling ``ADCPClient`` inside the same
7353+ ``with`` block generates a fresh key and emits a ``UserWarning`` —
7354+ keys must be unique per (seller, request) pair (AdCP #2315).
7355+ * **No nesting.** Nested ``use_idempotency_key`` on the same client
7356+ raises ``RuntimeError``.
7357+
7358+ Example::
7359+
7360+ with client.use_idempotency_key(campaign.stored_key):
7361+ result = await client.create_media_buy(request)
7362+ """
7363+ from adcp import _idempotency
7364+
7365+ _idempotency.validate_key(key)
7366+ token = self._idempotency_client_token
7367+ if token in _idempotency._scoped_keys:
7368+ raise RuntimeError(
7369+ "use_idempotency_key is already active on this client; "
7370+ "nested usage is not supported."
7371+ )
7372+ _idempotency._scoped_keys[token] = key
7373+ try:
7374+ yield key
7375+ finally:
7376+ _idempotency._scoped_keys.pop(token, None)</ code > </ pre >
7377+ </ details >
7378+ < div class ="desc "> < p > Pin an < code > idempotency_key</ code > for the next mutating call on THIS client.</ p >
7379+ < p > Use when you've persisted a key (e.g., in a buyer-side database) and
7380+ want the SDK to send that exact key on resume or retry across process
7381+ restarts. The key is validated against < code > ^[A-Za-z0-9_.:-]{16,255}$</ code > on
7382+ entry; a < code > ValueError</ code > is raised for malformed keys.</ p >
7383+ < p > Scope rules:</ p >
7384+ < ul >
7385+ < li > < strong > Single-use within scope.</ strong > The first mutating call inside the
7386+ < code > with</ code > block consumes the pinned key; a second mutating call falls
7387+ through to a fresh UUID. This protects against < code > asyncio.gather</ code >
7388+ siblings accidentally sharing the key (which would trigger
7389+ < code > IDEMPOTENCY_CONFLICT</ code > or silently duplicate work). If you need to
7390+ retry, wrap each attempt in its own < code > with</ code > block.</ li >
7391+ < li > < strong > Client-scoped.</ strong > The pinned key applies only to calls on THIS
7392+ client. A mutating call on a sibling < code > < a title ="adcp.client.ADCPClient " href ="#adcp.client.ADCPClient "> ADCPClient</ a > </ code > inside the same
7393+ < code > with</ code > block generates a fresh key and emits a < code > UserWarning</ code > —
7394+ keys must be unique per (seller, request) pair (AdCP #2315).</ li >
7395+ < li > < strong > No nesting.</ strong > Nested < code > use_idempotency_key</ code > on the same client
7396+ raises < code > RuntimeError</ code > .</ li >
7397+ </ ul >
7398+ < p > Example::</ p >
7399+ < pre > < code > with client.use_idempotency_key(campaign.stored_key):
7400+ result = await client.create_media_buy(request)
7401+ </ code > </ pre > </ div >
7402+ </ dd >
72227403< dt id ="adcp.client.ADCPClient.validate_content_delivery "> < code class ="name flex ">
72237404< span > async def < span class ="ident "> validate_content_delivery</ span > </ span > (< span > self, request: ValidateContentDeliveryRequest) ‑> < a title ="adcp.types.core.TaskResult " href ="types/core.html#adcp.types.core.TaskResult "> TaskResult</ a > [Union[ValidateContentDeliveryResponse1, ValidateContentDeliveryResponse2]]</ span >
72247405</ code > </ dt >
@@ -7581,6 +7762,7 @@ <h4><code><a title="adcp.client.ADCPClient" href="#adcp.client.ADCPClient">ADCPC
75817762< li > < code > < a title ="adcp.client.ADCPClient.update_content_standards " href ="#adcp.client.ADCPClient.update_content_standards "> update_content_standards</ a > </ code > </ li >
75827763< li > < code > < a title ="adcp.client.ADCPClient.update_media_buy " href ="#adcp.client.ADCPClient.update_media_buy "> update_media_buy</ a > </ code > </ li >
75837764< li > < code > < a title ="adcp.client.ADCPClient.update_property_list " href ="#adcp.client.ADCPClient.update_property_list "> update_property_list</ a > </ code > </ li >
7765+ < li > < code > < a title ="adcp.client.ADCPClient.use_idempotency_key " href ="#adcp.client.ADCPClient.use_idempotency_key "> use_idempotency_key</ a > </ code > </ li >
75847766< li > < code > < a title ="adcp.client.ADCPClient.validate_content_delivery " href ="#adcp.client.ADCPClient.validate_content_delivery "> validate_content_delivery</ a > </ code > </ li >
75857767</ ul >
75867768</ li >
0 commit comments