Skip to content

IdempotencyKeyMiddleware: PSR-15 middleware with request-hash + in-progress lifecycle #173

@tonydspaniard

Description

@tonydspaniard

Part of #171. Depends on #172 (storage).

Goal

Add Altair\Idempotency\Middleware\IdempotencyKeyMiddleware — a PSR-15 middleware that intercepts the Idempotency-Key request header, coordinates with the store from #172, and either replays the cached response or runs the handler and caches the outcome.

Why

The middleware is where the contract becomes operational. Everything upstream (the spec block, the OpenAPI extension, the round-trip gate) configures and exposes this middleware; everything downstream (Stripe-style retries, agent loops, queue retry storms) depends on it doing the right thing.

Behaviour matrix

Situation Response
Header absent (mode=optional) Pass through; no caching.
Header absent (mode=required) 400 Bad Request with { error: "Idempotency-Key header required for this endpoint" }.
Header present, key unseen Claim key + hash; run handler; cache response; return it.
Header present, key seen with same hash, completed Return cached response (status + allow-listed headers + body), add Idempotency-Replayed: true header.
Header present, key seen with same hash, in-progress Wait briefly (configurable, default ~50ms × N retries), then return cached or 409 if still in-progress.
Header present, key seen with different hash 409 Conflict with { error: "Idempotency-Key reused with a different payload" }.
Header present, handler throws Release the claim; propagate exception. The next attempt with the same key starts fresh.
Request method is GET/HEAD/OPTIONS Pass through; idempotency only applies to mutating methods.
Response body is streaming (transfer-encoding: chunked or text/event-stream) Pass through; don't cache.

The Idempotency-Replayed: true header on a cached return lets consumers (and observability) tell a fresh execution from a replay without inspecting state.

Header validation

  • Reject keys longer than 255 characters (returns 400).
  • Reject keys containing control characters or whitespace (returns 400).
  • Accept any other printable ASCII / UTF-8 string. Don't enforce UUID-only — Stripe doesn't, and the friction stops adoption.

Request body hash

Hash the full request body bytes with SHA-256 (raw bytes, not parsed JSON, so semantically-equivalent representations like attribute reordering DO produce different hashes — this is a deliberate "no surprises" choice; tools that want JSON-canonical hashing add an upstream middleware to canonicalise first).

The hasher is a separate class (Altair\Idempotency\Hash\RequestBodyHasher) so a host application can swap it via DI.

Acceptance criteria

  • IdempotencyKeyMiddleware implements MiddlewareInterface (PSR-15) with constructor that takes a IdempotencyStoreInterface + int $ttlSeconds + string $mode (optional/required) + ?RequestBodyHasher $hasher
  • Pass-through behaviour for GET/HEAD/OPTIONS is verified by test
  • Replay path returns identical status + body + allow-listed headers + adds Idempotency-Replayed: true
  • 409 path returns the documented error envelope with correlation-friendly fields
  • In-progress wait + retry is bounded and configurable; defaults are reasonable (≤ 500ms total wait)
  • Handler exception path releases the claim (verified by test)
  • Streaming-response path skips caching (verified by emitting a text/event-stream response and asserting the store was not written)
  • Tests use InMemoryStore exclusively so the suite runs without Redis / APCu
  • Coverage 80%+ on the new namespace

Out of scope

  • The spec block that wires this middleware — own issue
  • The OpenAPI round-trip activation — own issue
  • A IdempotencyConfiguration for the container (it lands when the spec-block wiring needs it)

Notes

The Idempotency-Replayed: true header is non-standard but matches what GitHub, Stripe, and Twilio all do (under slightly different header names). Pick Idempotency-Replayed: true and document it; consistency across responses is the value.

Metadata

Metadata

Assignees

No one assigned

    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