Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat: Idempotency-Key header on POST endpoints (cloud prereq, v0.4.0) #69

@ascender1729

Description

@ascender1729

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

  • All 358 existing functional tests pass (no behaviour change without the header).
  • New tests:
    • same Idempotency-Key + same body returns the original response (status, headers, body byte-identical)
    • same Idempotency-Key + different body returns 409 Conflict
    • missing Idempotency-Key behaves as today (no caching)
    • TTL expiration: after 24h the key can be reused
  • OpenAPI spec documents the Idempotency-Key header on every POST endpoint.
  • CLI tool gains a --idempotency-key flag on commands that issue POST requests, auto-generates UUIDv4 if not provided.
  • DCO sign-off.

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>

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

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