Enterprise Middleware
Idempotency Middleware
Let clients retry POST and PATCH requests safely with the Idempotency-Key header. The first response is stored and replayed, so the operation never runs twice.
Introduction
Note: This middleware is only available with Kipchak Enterprise.
Lets clients retry POST and PATCH requests safely. A client sends an Idempotency-Key header with a value it chooses, such as a UUID; if the request times out or the connection drops, it sends the same request with the same key again. The middleware makes sure the operation runs once: the first response is stored, and every retry gets that response back without the route running again.
This is what payment APIs (Stripe, Adyen, PayPal) do, and what the IETF Idempotency-Key header draft standardises.
- Runs once: the key is taken atomically in Valkey or Memcached, so concurrent retries on any worker or host cannot both run.
- Replays the response: status, headers and body, with
Idempotent-Replayed: true. - Refuses misuse: the same key with a different request is refused (422), and a retry while the first request is still running is told to wait (409).
- Safe retries after failures: server errors and exceptions free the key, so the retry runs again.
- Per consumer: keys are scoped to the consumer from Consumer Identity, so one client's keys never match another's.
- Fails closed: if the store is down, protected requests are refused with 503 rather than run unprotected (configurable).
Installation
To install this middleware, you need access to the Enterprise Composer repository at https://php.pkgs.1x.ax/.
composer require kipchak/middleware-idempotency
composer require kipchak/driver-valkey # or kipchak/driver-memcached
Copy the sample config to config/kipchak.idempotency.php, set 'enabled' => true, and add the middleware in middlewares/middlewares.php before the authentication middlewares:
use Kipchak\Middleware\Idempotency\Idempotency;
Idempotency::initialise($app);
// Then the authentication middlewares, e.g. AuthJwks::initialise($app);
Slim runs the last-added middleware first, so idempotency then runs after authentication: keys are scoped to the verified consumer, and unauthenticated requests never take a key. With 'enabled' => false, attach it to the routes that need it instead:
use Kipchak\Middleware\Idempotency\Handlers\Idempotent;
$app->post('/v1/payments', [Payments::class, 'create'])->add(new Idempotent($app->getContainer()));
Configuration
| Key | Default | Meaning |
|---|---|---|
enabled | false | Protect every request with a covered method |
header | Idempotency-Key | The request header carrying the key |
methods | POST, PATCH | Methods protected; GET, PUT and DELETE are idempotent already |
required | false | Refuse covered requests without a key (400) |
ttl | 86400 | Seconds a response is kept for replay |
lock_timeout | 60 | Seconds a request may run before a retry may run it again. Set it longer than your slowest request. |
store | valkey | valkey, memcached, or service (a container entry) |
valkey_pool, memcached_pool | cache | The driver's pool |
fail_open | false | When the store is down: false answers 503, true runs requests unprotected |
store_server_errors | false | Store and replay 5xx responses instead of freeing the key |
consumer | from kipchak.identity | Who keys belong to |
anonymous | allow | Requests with no consumer share one scope (allow) or are refused with 401 (reject) |
ignore_paths | none | Paths never protected |
max_body_size | 10 MiB | Larger request bodies are refused with 413 |
How it answers
| Situation | Response |
|---|---|
| First request with a key | The route runs; its response is stored |
| Same key, same request, after it finished | The stored response, with Idempotent-Replayed: true |
| Same key, same request, still running | 409 idempotency_request_in_progress, Retry-After: 1 |
| Same key, different request | 422 idempotency_key_reused |
| No key | The route runs unprotected, or 400 idempotency_key_missing with required |
| Key longer than 255 characters or not visible ASCII | 400 idempotency_key_invalid |
| Store unavailable | 503 idempotency_store_unavailable, Retry-After: 5 (or unprotected with fail_open) |
"The same request" means the same method, path, query parameters (in any order), Content-Type and body.
Responses are stored whatever their status, so a declined payment (402) or a validation error (422) is replayed too: it is the answer to that request. These free the key instead, so a retry runs the request again:
- server errors (5xx), unless
store_server_errorsis on, and exceptions; 408,409,425and429, which mean the request was not processed.
Replayed responses keep their headers, except those that belong to one response: Set-Cookie, Date, Content-Length, Transfer-Encoding, Connection and Keep-Alive.
Stores
Valkey (Kipchak Enterprise): a key is taken with one SET NX EX, and a response is stored, or a key released, by a Lua script that first checks the request still owns it. A request that outlived lock_timeout therefore cannot overwrite the record of a request that took the key over; it logs a warning instead. Use cluster mode, not client-side sharding (RedisArray), which cannot take a key atomically.
Memcached: add() takes a key and cas() stores the response. Memcached evicts items when it runs out of memory, so stored responses can disappear before ttl: size it for the records you keep, or use Valkey.
Your own: implement Kipchak\Middleware\Idempotency\Store\IdempotencyStore and register it in the container, then set 'store' => 'service', 'service' => '<container key>'. Its operations must be atomic across hosts.
Records are stored under idempotency:<sha256 of consumer and key>, so keys never appear in the store in clear. Each holds the full response body, so budget for ttl × request rate × response size.
FrankenPHP worker mode
Settings and the store are built once per worker; nothing about a request is kept after it. The request body is read once and passed to the route as a fresh stream, because Slim's php://input stream repeats its contents if read twice. phpredis clients stay broken after a command fails during a Valkey outage, so the Valkey store reconnects through the Valkey driver and retries once.
Git Repository
The source code for this middleware is hosted internally.