feat: idempotency_key auto-injection, typed errors, and capability gate#194
Merged
Conversation
Closes #181. Implements the client-side ergonomics of AdCP #2315 (idempotency_key now required on every mutating request). ## Feature - Auto-generate UUID v4 idempotency_key on every mutating tool call when the caller omits one — zero-config retry safety against sellers that enforce AdCP #2315. Applies to all 28 mutating tasks in the spec. - `client.use_idempotency_key(key)` contextmanager for bring-your-own-key, e.g. persisting keys in a DB alongside a campaign row for replay after process restart. Single-use within the scope: the first mutating call consumes the pinned key; concurrent gather() siblings fall through to fresh UUIDs. Cross-client reuse inside the same block emits a `UserWarning` and generates a fresh key (keys must be unique per (seller, request) pair). - First-class `result.idempotency_key` and `result.replayed` fields on `TaskResult` — populated on both success and failure paths so buyers can correlate retries. - Typed exceptions: `IdempotencyConflictError`, `IdempotencyExpiredError`, `IdempotencyUnsupportedError`. Messages are FIXED (no server text forwarded) to prevent non-compliant sellers from leaking payload hints through the exception string. Actionable suggestions included. - `strict_idempotency=True` client option fails closed before the first mutating call if the seller hasn't declared `adcp.idempotency.replay_ttl_seconds` in capabilities. Defaults to False for backward compatibility. - Key format validation: `^[A-Za-z0-9_.:-]{16,255}$`. Client-side length and charset checks with specific error messages. - Debug-log redaction: request params and response bodies are deep-redacted (recursive walk of dicts, lists, and Pydantic models) before landing in `DebugInfo`. Keys inside the seller's replay TTL window are a retry-pattern oracle and must not appear in persistent logs. - MCP text-only error fallback: servers that return `is_error=true` with plain text like "IDEMPOTENCY_CONFLICT: ..." (FastMCP default) get the code parsed out of the message and mapped to the typed exception. ## Testing - 70 new tests across `tests/test_idempotency.py` (62) and `tests/test_idempotency_storyboard.py` (8 — one per AdCP compliance storyboard phase). 1284 total tests passing; mypy clean (643 files); ruff clean. - Live-tested against the AdCP reference training agent at `test-agent.adcontextprotocol.org/mcp/` on 2026-04-18. Client correctly generates UUIDs, sends them on the wire, and surfaces them on the result. Training agent declares the capability but does not enforce replay/conflict semantics — filed adcp#2346. A separate MCP adapter gap (text-JSON vs structuredContent) blocks end-to-end live tests; filed as #193 for a follow-up PR. ## Compatibility - Non-breaking for `ADCPClient` construction: `strict_idempotency` defaults to False. - TaskResult gains two new optional fields with defaults — additive. - Schema side (PR #184) now requires `idempotency_key` at Pydantic construction, so callers building request objects directly must pass a key. README has updated examples. Existing callers using `.simple` API or passing requests with keys keep working. - Adapter error-passthrough narrowed to `(IdempotencyConflictError, IdempotencyExpiredError)` only — `ADCPConnectionError`/`ADCPTimeoutError` continue to be converted to `TaskResult(failed)` as before. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced Apr 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #181. Implements the client-side ergonomics of AdCP #2315 (idempotency_key now required on every mutating request).
Summary
idempotency_keyon every mutating tool call when the caller omits one. Applies to all 28 mutating tasks in the spec. Zero-config retry safety against any #2315-compliant seller.client.use_idempotency_key(key)contextmanager for bring-your-own-key (persist keys across process restarts, e.g. alongside a campaign row in your DB). Single-use within scope — concurrentgathersiblings get fresh UUIDs instead of duplicating the pinned key. Cross-client reuse inside the same block emits aUserWarning(AdCP #2315: keys must be unique per(seller, request)pair).result.idempotency_keyandresult.replayedonTaskResult— populated on success and failure so buyers can correlate retries.IdempotencyConflictError,IdempotencyExpiredError,IdempotencyUnsupportedError. Fixed messages (no server text forwarded) prevent non-compliant sellers from leaking payload hints through the exception string. Actionable recovery hints included.strict_idempotency=Trueclient option fails closed before the first mutating call if the seller hasn't declaredadcp.idempotency.replay_ttl_seconds. Defaults to False.^[A-Za-z0-9_.:-]{16,255}$with specific length vs charset error messages.DebugInfo.is_error=truewith plain text like"IDEMPOTENCY_CONFLICT: ..."(FastMCP default) get the code parsed from the message and mapped to the typed exception.Testing
tests/test_idempotency.py(62 units + adapter integration + gather semantics + Pydantic round-trip + wire-format via httpx MockTransport) +tests/test_idempotency_storyboard.py(8 — one per AdCP compliance storyboard phase).test-agent.adcontextprotocol.org/mcp/on 2026-04-18:replay_ttl_seconds=86400TextContent, notstructuredContent). Filed as #193 for a separate follow-up PR.Behavioral note
Adapter error-passthrough is narrowed to
(IdempotencyConflictError, IdempotencyExpiredError)only —ADCPConnectionError/ADCPTimeoutErrorcontinue to be converted toTaskResult(failed)as before, preserving the existing caller contract.Docs
README has a new "Idempotency and retries" section covering:
use_idempotency_key(otherwise each retry gets a new UUID)Follow-ups (not in scope)
Test plan
.venv/bin/pytest tests/ --no-cov -q— 1284 passed.venv/bin/mypy src/adcp/— 0 errors, 643 files.venv/bin/ruff check src/adcp/ tests/test_idempotency.py tests/test_idempotency_storyboard.py— clean🤖 Generated with Claude Code