Skip to content

Commit 6e992e5

Browse files
committed
deploy: af9dd2d
1 parent 450677a commit 6e992e5

9 files changed

Lines changed: 847 additions & 40 deletions

File tree

client.html

Lines changed: 186 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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
&#34;&#34;&#34;
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&#39;t declare it provide no retry-safety guarantee
93+
per AdCP #2315. Defaults to False for backward compatibility.
8794
&#34;&#34;&#34;
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&#34;Unsupported protocol: {agent_config.protocol}&#34;)
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) -&gt; None:
134+
&#34;&#34;&#34;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+
&#34;&#34;&#34;
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, &#34;adcp&#34;, None)
156+
idempotency_info = getattr(adcp_info, &#34;idempotency&#34;, None) if adcp_info else None
157+
ttl = (
158+
getattr(idempotency_info, &#34;replay_ttl_seconds&#34;, 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) -&gt; str:
116171
&#34;&#34;&#34;Generate webhook URL for a task.&#34;&#34;&#34;
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) -&gt; Iterator[str]:
188+
&#34;&#34;&#34;Pin an ``idempotency_key`` for the next mutating call on THIS client.
189+
190+
Use when you&#39;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+
&#34;&#34;&#34;
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+
&#34;use_idempotency_key is already active on this client; &#34;
222+
&#34;nested usage is not supported.&#34;
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
&#34;&#34;&#34;
27912890
if not self.webhook_secret:
2792-
logger.warning(
2793-
&#34;Webhook signature verification skipped: no webhook_secret configured&#34;
2794-
)
2891+
logger.warning(&#34;Webhook signature verification skipped: no webhook_secret configured&#34;)
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
33143411
required features before making task calls (e.g., sync_audiences requires
33153412
audience_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) -&gt; Iterator[str]:
7336+
&#34;&#34;&#34;Pin an ``idempotency_key`` for the next mutating call on THIS client.
7337+
7338+
Use when you&#39;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+
&#34;&#34;&#34;
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+
&#34;use_idempotency_key is already active on this client; &#34;
7370+
&#34;nested usage is not supported.&#34;
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

Comments
 (0)