Skip to content

Idempotency-Key primitive — Stripe-style middleware + storage + spec block (epic) #171

@tonydspaniard

Description

@tonydspaniard

Goal

Ship a first-class Idempotency-Key primitive: a small univeros/idempotency sub-package providing a storage contract + adapters + a PSR-15 middleware + an idempotency: spec block the scaffolder wires automatically. Mirrors Stripe's idempotency model — clients pass an Idempotency-Key header on mutating requests, the framework returns the cached response when the same key is replayed (and refuses with 409 when the key is replayed with a different request body).

The x-altair-idempotency OpenAPI extension key already exists (#163); this epic delivers the runtime that consumes it and the spec block that produces it.

Why

From the strategy notes: idempotency is one of the four "Laravel AI-era property-gaps" the framework targets (alongside determinism, reversibility, and webhooks/idempotency, agent-operable payments). Without a first-class primitive every mutating request an agent retries is a foot-gun — a duplicate row, a double charge, a re-dispatched job. With one, "retry the call" stays safe by default.

Stripe pioneered the pattern; every payments API now expects something like it. PHP frameworks don't ship this out of the box (Laravel has no native primitive, Symfony has none, Slim has none). Shipping it as a sub-package — pluggable storage, agent-readable spec block, round-trips through OpenAPI — is a credible, useful, framework-agnostic differentiator.

What success looks like

A user adds to their spec:

idempotency:
  ttl: 24h
  scope: tenant

Re-scaffolds. The emitted Action now consumes an Idempotency-Key middleware that:

  • Reads Idempotency-Key from request headers.
  • Hashes the request body.
  • Looks up the key (scoped to the tenant) in the configured store.
  • If found with matching hash → returns the cached response (same status, same headers, same body).
  • If found with different hash → returns 409 Conflict with { error: "Idempotency-Key reused with a different payload" }.
  • If not found → marks the key in-progress, lets the handler run, captures the response, persists it under the key, returns it.

The spec:emit-openapi chain writes x-altair-idempotency: { ttl: 24h, scope: tenant } on the operation; openapi:import recovers the spec block on the reverse path; openapi:roundtrip (#164) gains the extension to its compared set so dropping it in a refactor fails the gate.

Sub-issues

Acceptance criteria

  • composer require univeros/idempotency ships the storage interface + at least one adapter that does not require external infrastructure (in-memory for tests, APCu for single-host production)
  • A spec carrying idempotency: { ttl: 24h } scaffolds an Action whose pipeline includes IdempotencyKeyMiddleware configured with that TTL
  • Concurrent identical requests with the same Idempotency-Key see one execute and the other(s) get either the cached response or a 409, never a duplicate side-effect
  • A request with a missing Idempotency-Key header on an idempotency-protected endpoint either falls through (configurable: optional) or returns 400 (configurable: required) — default optional so adopting the middleware doesn't break existing clients
  • openapi:roundtrip (#164) catches a regression where x-altair-idempotency would be dropped
  • 80%+ test coverage on the new sub-package
  • Determinism: same spec → byte-identical scaffold + emitted OpenAPI

Out of scope

  • Webhook-specific idempotency. Webhooks have their own dedupe model (event id + delivery TTL) that should live alongside the webhook framework rather than here.
  • Saga / multi-step idempotency. Single-request scope only in v1.
  • Cross-region / multi-write replication of the store. Adapters target single-region clusters; multi-region is the host application's call.
  • Response replay for streaming endpoints. The middleware skips the cache when the response advertises transfer-encoding: chunked or content-type text/event-stream.

Dependencies

  • univeros/http (PSR-15 middleware contract)
  • univeros/cache (the Redis / APCu adapter can defer to existing CacheItemStorage implementations rather than re-inventing connection management)
  • x-altair-idempotency extension key + schema (already shipped — #163)

Notes

Sub-package layout follows univeros/cookie, univeros/session, etc.:

src/Altair/Idempotency/
├── Contracts/
│   ├── IdempotencyStoreInterface.php
│   └── IdempotencyKeyResolverInterface.php
├── Storage/
│   ├── InMemoryStore.php
│   ├── ApcuStore.php
│   └── RedisStore.php
├── Middleware/
│   └── IdempotencyKeyMiddleware.php
├── Hash/
│   └── RequestBodyHasher.php
├── Exception/
│   ├── IdempotencyConflictException.php
│   └── IdempotencyException.php
├── Configuration/
│   └── IdempotencyConfiguration.php
└── composer.json

Then the scaffold sub-package gains an IdempotencySpec AST node and the action emitter wires the middleware into the generated pipeline when the spec carries the block.

Suggested order

#172#173#174#175#176. Each depends on the prior. #176 is the wrap-up and can land in parallel with #175 if scope allows.

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