feat(idempotency): IdempotencyKeyMiddleware + RequestBodyHasher (#173)#178
Merged
Conversation
Adds the PSR-15 middleware that drives the storage layer from #172. Reads Idempotency-Key from the request, hashes the request body, and coordinates with the store so a replayed request returns the original response and a key reused with a different payload is rejected with 409. Behaviour matrix - GET / HEAD / OPTIONS → pass through; no caching. - Header absent, mode=optional → pass through. - Header absent, mode=required → 400 with {error} envelope. - Header malformed (>255 chars, ctrl chars, or whitespace) → 400. - Key unseen → claim; execute; cache; return. - Key seen, same hash, completed → replay + Idempotency-Replayed: true. - Key seen, same hash, in-progress → wait + retry up to maxWaitMs; replay when ready; 409 on timeout or release. - Key seen, different hash → 409. - Handler throws → release claim; re-throw. - Streaming response (chunked or text/event-stream) → pass through without caching. Headers - Response headers are stored on an allow-list (default Content-Type, Location, Link). Set-Cookie / Authorization / anything not on the list is never written to shared storage. - Idempotency-Replayed: true header on every cached return distinguishes a fresh execution from a replay for observability + agents. RequestBodyHasher - SHA-256 over the raw bytes; not parsed JSON. Semantically equivalent bodies with different whitespace produce different hashes — the rule is "no surprises about what bytes hashed". - Rewinds the body stream after reading so downstream handlers see the same content from position 0. Composer - univeros/idempotency picks up PSR HTTP message + factory + server-middleware deps. No coupling to univeros/http; the package stays portable. Tests (20 new) - RequestBodyHasher: byte equality, whitespace sensitivity, rewind guarantee, empty-body determinism. - IdempotencyKeyMiddleware: safe-method passthrough, optional / required mode header handling, malformed key, fresh execution + cache, replay + Idempotency-Replayed header, sensitive-header filtering, hash-mismatch 409, streaming + chunked skip-cache, handler-throw release, in-progress timeout, in-progress release, body-rebuild for downstream emission. Out of scope (per #171) - The idempotency: spec block + scaffolder integration — #174. - The x-altair-idempotency round-trip activation — #175. - The package doc + benchmark variant — #176. Part of #171. Closes #173.
This was referenced May 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #173. Part of #171. Depends on #172.
Summary
Adds the PSR-15 middleware that drives the #172 storage layer. Reads the
Idempotency-Keyheader, hashes the request body, and coordinates with the store so a replayed request returns the original response and a key reused with a different payload is rejected with409.Behaviour matrix
optionalrequired400with{error}envelope.400.Idempotency-Replayed: true.409.409.text/event-stream)Header policy
Response headers are stored on an allow-list (default
Content-Type,Location,Link).Set-Cookie,Authorization, and anything not on the list are never written to shared storage. Verified by test.RequestBodyHasher
SHA-256 over the raw bytes \xe2\x80\x94 not parsed JSON. Semantically equivalent bodies with different whitespace produce different hashes. The hasher rewinds the body stream after reading so downstream handlers see the content from position 0.
Files
src/Altair/Idempotency/Middleware/IdempotencyKeyMiddleware.phpsrc/Altair/Idempotency/Hash/RequestBodyHasher.phppsr/http-factory,psr/http-message,psr/http-server-handler,psr/http-server-middlewareadded touniveros/idempotency(no coupling touniveros/http)tests/Idempotency/Hash/RequestBodyHasherTest.php,tests/Idempotency/Middleware/IdempotencyKeyMiddlewareTest.php(20 new)Test plan
composer cs\xe2\x80\x94 greencomposer stan\xe2\x80\x94 greencomposer rector(full tree, no cache) \xe2\x80\x94 greencomposer test\xe2\x80\x94 6275 tests (+20 new), 0 new failuresbin/altair manifest:generate\xe2\x80\x94 cleanOut of scope
idempotency:spec block + scaffolder wiring \xe2\x80\x94 idempotency: spec block + scaffolder integration (AST + parser + validator + emitter wiring) #174.x-altair-idempotencyround-trip activation \xe2\x80\x94 x-altair-idempotency round-trip activation: forward emit + reverse import + drift gate #175.