Skip to content

Server-side idempotency middleware with pluggable storage #182

Description

@bokelley

Tracking issue for the server-side implementation helper for AdCP #2308 — idempotency_key is now required on every mutating request. The spec defines the contract; this issue is about giving Python sellers a drop-in middleware so they don't have to build the storage/atomicity layer themselves.

Note: the repo is currently described as "a python library to interact with adcp servers" (client-focused). This issue expands the scope to server-side helpers, matching the TS SDK which covers both. If it's a better fit to split into a separate adcp-server-python package, happy to re-file there.

Sibling issues:

Why a middleware

Sellers have to implement four things correctly, and the atomicity bit is what trips people up:

  1. Extract idempotency_key from the request
  2. Look up (principal_id, idempotency_key) in a store with TTL
  3. If hit: compare canonical payload hash → return cached response, OR raise IDEMPOTENCY_CONFLICT
  4. If miss: execute the handler, then atomically commit side effects + cache entry. A crash between "fire webhook" and "cache response" means the retry fires the webhook again.

Proposed API

from adcp_client.server import IdempotencyStore, canonical_json_sha256

idempotency = IdempotencyStore(
    backend=PgBackend(engine),   # or RedisBackend, or MemoryBackend for tests
    ttl_seconds=86400,
    hash_fn=canonical_json_sha256,
)

# Decorator composes with FastAPI / your MCP server framework
@idempotency.wrap
async def create_media_buy(req: CreateMediaBuyRequest, ctx: Context) -> CreateMediaBuyResponse:
    # Business logic — same transaction as the cache write
    media_buy = await db.insert_media_buy(req, ctx.tx)
    await fire_webhook(media_buy)
    return media_buy

The wrapper:

  • Extracts idempotency_key from the request
  • Scopes lookups by ctx.principal.id (per-principal namespace — security requirement)
  • On hit with matching payload hash: returns cached response
  • On hit with different hash: raises IdempotencyConflictError → mapped to IDEMPOTENCY_CONFLICT
  • On miss: runs handler inside a transaction, writes {key, hash, response} alongside business writes, commits together

Backends

Ship three, MVP needs the first two:

  • MemoryBackend() — in-process dict with TTL sweeper, for tests and reference agents
  • PgBackend(engine, schema=None, table=None) — SQLAlchemy / asyncpg, matches transaction guarantees when business data is in Postgres
  • RedisBackend(client)redis.asyncio client with TTL-keyed entries. Document the atomicity caveat (can't transact with business DB)

Capabilities integration

capabilities.adcp.idempotency = idempotency.capability()
# → { replay_ttl_seconds: 86400 }

Docs

  • "Why idempotency_key and what it costs" narrative (pointing at security.mdx)
  • Snippet for each backend
  • Gotchas: atomicity across backend choice, per-principal scoping, TTL implications

Acceptance

  • IdempotencyStore with MemoryBackend + PgBackend
  • canonical_json_sha256 hash helper (stable key order, whitespace-normalized)
  • IdempotencyConflictError exception mapped to IDEMPOTENCY_CONFLICT
  • @wrap decorator + async support
  • .capability() method returns the capabilities fragment
  • Passes the idempotency compliance storyboard end-to-end

Related: adcontextprotocol/adcp#2308, adcontextprotocol/adcp#2315, #181 (client side)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions