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
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.
Part of #171. Depends on #172 (storage).
Goal
Add
Altair\Idempotency\Middleware\IdempotencyKeyMiddleware— a PSR-15 middleware that intercepts theIdempotency-Keyrequest 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
optional)required)400 Bad Requestwith{ error: "Idempotency-Key header required for this endpoint" }.Idempotency-Replayed: trueheader.409if still in-progress.409 Conflictwith{ error: "Idempotency-Key reused with a different payload" }.GET/HEAD/OPTIONStransfer-encoding: chunkedortext/event-stream)The
Idempotency-Replayed: trueheader on a cached return lets consumers (and observability) tell a fresh execution from a replay without inspecting state.Header validation
400).400).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 aIdempotencyStoreInterface+int $ttlSeconds+string $mode(optional/required) +?RequestBodyHasher $hasherGET/HEAD/OPTIONSis verified by testIdempotency-Replayed: truetext/event-streamresponse and asserting the store was not written)InMemoryStoreexclusively so the suite runs without Redis / APCuOut of scope
IdempotencyConfigurationfor the container (it lands when the spec-block wiring needs it)Notes
The
Idempotency-Replayed: trueheader is non-standard but matches what GitHub, Stripe, and Twilio all do (under slightly different header names). PickIdempotency-Replayed: trueand document it; consistency across responses is the value.