Skip to content

feat: idempotency_key auto-injection, typed errors, and capability gate#194

Merged
bokelley merged 1 commit into
mainfrom
bokelley/issue-181-idempotency
Apr 18, 2026
Merged

feat: idempotency_key auto-injection, typed errors, and capability gate#194
bokelley merged 1 commit into
mainfrom
bokelley/issue-181-idempotency

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

Closes #181. Implements the client-side ergonomics of AdCP #2315 (idempotency_key now required on every mutating request).

Summary

  • Auto-inject UUID v4 idempotency_key on 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 — concurrent gather siblings get fresh UUIDs instead of duplicating the pinned key. Cross-client reuse inside the same block emits a UserWarning (AdCP #2315: keys must be unique per (seller, request) pair).
  • First-class result.idempotency_key and result.replayed on TaskResult — populated on success and failure so buyers can correlate retries.
  • Typed exceptions 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=True client option fails closed before the first mutating call if the seller hasn't declared adcp.idempotency.replay_ttl_seconds. Defaults to False.
  • Key format validation ^[A-Za-z0-9_.:-]{16,255}$ with specific length vs charset 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.
  • MCP text-only error fallback: servers that return is_error=true with plain text like "IDEMPOTENCY_CONFLICT: ..." (FastMCP default) get the code parsed from the message and mapped to the typed exception.

Testing

  • 70 new teststests/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).
  • 1284 total pytest passing, 8 skipped, 6 warnings. mypy clean (643 files). ruff clean on idempotency surface.
  • Live-tested against the AdCP reference training agent at test-agent.adcontextprotocol.org/mcp/ on 2026-04-18:
    • Phase 1 (capability discovery) ✅ — seller declares replay_ttl_seconds=86400
    • Phase 5 (fresh keys → distinct media_buys) ✅ — proves SDK sends keys on wire and surfaces them on results
    • Phases 3 (replay) and 4 (conflict) ❌ — training agent advertises idempotency but does not enforce it; filed upstream as adcp#2346
  • SDK gap uncovered during live testing: MCP adapter rejects text-JSON responses (training agent returns JSON inside TextContent, not structuredContent). Filed as #193 for a separate follow-up PR.

Behavioral note

Adapter error-passthrough is narrowed to (IdempotencyConflictError, IdempotencyExpiredError) only — ADCPConnectionError/ADCPTimeoutError continue to be converted to TaskResult(failed) as before, preserving the existing caller contract.

Docs

README has a new "Idempotency and retries" section covering:

  • Pass a fresh UUID v4 per logical operation (Pydantic construction now requires it per #2315)
  • Retry-loop pattern wrapping use_idempotency_key (otherwise each retry gets a new UUID)
  • BYO-key for process-restart recovery
  • Typed errors and strict mode
  • Security note on httpx/httpcore DEBUG logging leaking full keys

Follow-ups (not in scope)

  • adcp-client-python#193 — MCP adapter text-JSON fallback (blocks live testing against reference seller)
  • adcp#2346 — training agent needs to enforce declared idempotency semantics
  • Nightly integration test against a #2315-compliant seller once one exists

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
  • Live probe against AdCP training agent — client-side verified, seller-side gap filed upstream

🤖 Generated with Claude Code

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>
@bokelley bokelley merged commit af9dd2d into main Apr 18, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add idempotency_key auto-generation and IdempotencyConflictError

1 participant