Skip to content

feat(idempotency): IdempotencyKeyMiddleware + RequestBodyHasher (#173)#178

Merged
tonydspaniard merged 1 commit into
masterfrom
feat/173-idempotency-middleware
May 31, 2026
Merged

feat(idempotency): IdempotencyKeyMiddleware + RequestBodyHasher (#173)#178
tonydspaniard merged 1 commit into
masterfrom
feat/173-idempotency-middleware

Conversation

@tonydspaniard
Copy link
Copy Markdown
Member

Closes #173. Part of #171. Depends on #172.

Summary

Adds the PSR-15 middleware that drives the #172 storage layer. Reads the Idempotency-Key header, 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

Situation Response
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, whitespace) 400.
Key unseen Claim; execute; cache; return.
Key seen, same hash, completed Replay + Idempotency-Replayed: true.
Key seen, same hash, in-progress (\xe2\x89\xa4 maxWait) Wait + retry; replay when ready.
Key seen, same hash, in-progress (> maxWait) 409.
Key seen, different hash 409.
Handler throws Release claim; re-throw.
Streaming response (chunked or text/event-stream) Pass through without caching.

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

Layer Files
Middleware src/Altair/Idempotency/Middleware/IdempotencyKeyMiddleware.php
Hasher src/Altair/Idempotency/Hash/RequestBodyHasher.php
Composer psr/http-factory, psr/http-message, psr/http-server-handler, psr/http-server-middleware added to univeros/idempotency (no coupling to univeros/http)
Tests tests/Idempotency/Hash/RequestBodyHasherTest.php, tests/Idempotency/Middleware/IdempotencyKeyMiddlewareTest.php (20 new)

Test plan

  • composer cs \xe2\x80\x94 green
  • composer stan \xe2\x80\x94 green
  • composer rector (full tree, no cache) \xe2\x80\x94 green
  • composer test \xe2\x80\x94 6275 tests (+20 new), 0 new failures
  • bin/altair manifest:generate \xe2\x80\x94 clean

Out of scope

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.
@tonydspaniard tonydspaniard merged commit 3dae23e into master May 31, 2026
4 checks passed
@tonydspaniard tonydspaniard deleted the feat/173-idempotency-middleware branch May 31, 2026 03:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant