Problem
POST endpoints in api/main.py (and the routers it mounts under api/routers/) are not idempotent. A network retry on POST /v1/identities creates a duplicate UAIT. A retried POST /v1/credentials/issue issues two credentials with the same subject. This is a footgun for any client that retries on transient failures, which is most of them.
Stripe, AWS, and most modern APIs solve this with an Idempotency-Key header: the server stores the response keyed on (client, key) for some TTL, and a retry returns the cached response.
Proposed solution
Add Idempotency-Key header support to all POST endpoints in api/:
# api/middleware/idempotency.py (new file)
from fastapi import Request, HTTPException
from typing import Callable, Awaitable
import hashlib
import json
from datetime import datetime, timedelta, timezone
class IdempotencyStore(Protocol):
"""Pluggable backend for idempotency cache."""
def get(self, key: str) -> Optional[CachedResponse]: ...
def put(self, key: str, value: CachedResponse, ttl_seconds: int) -> None: ...
class InMemoryIdempotencyStore:
"""Default OSS backend - simple dict with TTL."""
def __init__(self):
self._cache: dict[str, tuple[CachedResponse, datetime]] = {}
def get(self, key):
entry = self._cache.get(key)
if not entry:
return None
cached, expires_at = entry
if datetime.now(timezone.utc) > expires_at:
del self._cache[key]
return None
return cached
def put(self, key, value, ttl_seconds):
expires = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
self._cache[key] = (value, expires)
FastAPI middleware that intercepts POST requests:
async def idempotency_middleware(request: Request, call_next):
if request.method != "POST":
return await call_next(request)
idem_key = request.headers.get("Idempotency-Key")
if not idem_key:
return await call_next(request)
# Hash the request body so we can detect mismatched-body retries
body = await request.body()
body_hash = hashlib.sha256(body).hexdigest()
cache_key = f"{idem_key}:{request.url.path}"
cached = idempotency_store.get(cache_key)
if cached:
if cached.body_hash != body_hash:
raise HTTPException(
status_code=409,
detail="Idempotency-Key reused with different request body",
)
return Response(
content=cached.response_body,
status_code=cached.status_code,
media_type="application/json",
)
response = await call_next(request)
# cache successful 2xx responses
if 200 <= response.status_code < 300:
idempotency_store.put(
cache_key,
CachedResponse(body_hash=body_hash, status_code=response.status_code, response_body=...),
ttl_seconds=24 * 3600,
)
return response
Wire the middleware in api/main.py after CORS and before routers.
Acceptance criteria
Dependencies
Independent. Can be worked alongside any other PR.
Cloud context
Cloud's API gateway (Fastify) implements the same pattern with a Postgres-backed IdempotencyStore and a unique constraint on (workspace_id, idempotency_key). This OSS PR ships only the in-memory default - perfect for single-process self-host.
Reference: attestix-cloud-plan/02-OPEN-CORE.md "What changes in attestix because of cloud".
Suggested commit message
feat(api): Idempotency-Key header support on POST endpoints
Stripe-pattern idempotency on POST endpoints in api/main.py and the
routers under api/routers/. Adds api/middleware/idempotency.py with
an IdempotencyStore Protocol and an InMemoryIdempotencyStore default
(24h TTL).
Same Idempotency-Key + same body returns the cached 2xx response.
Same key + different body returns 409 Conflict. Missing header behaves
as today.
CLI gains --idempotency-key flag on POST-shaped commands.
Refs: #<issue>
Problem
POST endpoints in
api/main.py(and the routers it mounts underapi/routers/) are not idempotent. A network retry onPOST /v1/identitiescreates a duplicate UAIT. A retriedPOST /v1/credentials/issueissues two credentials with the same subject. This is a footgun for any client that retries on transient failures, which is most of them.Stripe, AWS, and most modern APIs solve this with an
Idempotency-Keyheader: the server stores the response keyed on(client, key)for some TTL, and a retry returns the cached response.Proposed solution
Add
Idempotency-Keyheader support to all POST endpoints inapi/:FastAPI middleware that intercepts POST requests:
Wire the middleware in
api/main.pyafter CORS and before routers.Acceptance criteria
Idempotency-Keyheader on every POST endpoint.--idempotency-keyflag on commands that issue POST requests, auto-generates UUIDv4 if not provided.Dependencies
Independent. Can be worked alongside any other PR.
Cloud context
Cloud's API gateway (Fastify) implements the same pattern with a Postgres-backed
IdempotencyStoreand a unique constraint on(workspace_id, idempotency_key). This OSS PR ships only the in-memory default - perfect for single-process self-host.Reference:
attestix-cloud-plan/02-OPEN-CORE.md"What changes in attestix because of cloud".Suggested commit message