Skip to content

Server-side idempotency middleware with pluggable storage #182

@bokelley

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions