Idempotency
IdempotencyMiddleware protects write endpoints from duplicate processing by replaying the saved response when the same key is seen again.
IdempotencyMiddleware writes the cached response after the handler runs — two concurrent retries can both reach the handler. For side-effectful operations (charges, external calls, notifications), use AtomicIdempotencyMiddleware instead, which reserves the key before the handler executes.
Minimal example
use BetterRoute\Middleware\Write\IdempotencyMiddleware;
use BetterRoute\Middleware\Write\TransientIdempotencyStore;
$idempotency = new IdempotencyMiddleware(
store: new TransientIdempotencyStore(),
ttlSeconds: 300,
requireKey: true,
methods: ['POST', 'PATCH']
);
Persistent store (v0.3.0)
WpdbIdempotencyStore persists state to a custom wpdb table — useful when the object cache flushes or doesn't survive restarts. Install the schema once (typically on plugin activation):
use BetterRoute\Middleware\Write\IdempotencyMiddleware;
use BetterRoute\Middleware\Write\WpdbIdempotencyStore;
register_activation_hook(__FILE__, function (): void {
(new WpdbIdempotencyStore())->installSchema();
});
$idempotency = new IdempotencyMiddleware(
store: new WpdbIdempotencyStore(),
ttlSeconds: 600,
requireKey: true
);
Cross-database table names (containing .) are rejected at the storage boundary.
WpdbIdempotencyStore retains its Response::class deserialization allowlist. Since 1.1.0, classic middleware normalizes WP_REST_Response through StoredResponseCodec, but does not use the atomic store's data-only encoder. Return arrays/scalars or supported response envelopes, not arbitrary domain objects. The atomic wpdb store uses data-only encoding and allowed_classes => false.
How it works
- reads
Idempotency-Keyheader — (v1.1.0) keys longer thanmaxKeyLength(constructor param, default200) or containing non-printable-ASCII characters are rejected with400 idempotency_key_invalid - builds store key from route + idempotency key + identity — (v1.1.0) identity comes from the shared
RequestIdentityresolver:auth.userId/auth.subjectfrom an auth middleware, then the native logged-in WordPress user (get_current_user_id()) even when no auth middleware is attached, then an HMAC key id, falling back to'guest' - hashes request fingerprint (method + route + params/body/json) — (v1.1.0) over a deep canonical JSON (
Canonicalizer::json), so payload key order no longer changes the fingerprint - (v1.1.0) WordPress
WP_REST_Responseresults are normalized (status/data/headers preserved) before caching; a returnedWP_Erroris never cached - on replay with same fingerprint: returns cached response
- on replay with different fingerprint:
409 idempotency_conflict
Pass an explicit keyResolver to override the default identity-aware key.
Replayed Response gets header:
Idempotency-Replayed: true
Scenario: payment/order create endpoint
Use atomic idempotency for these side effects; classic replay alone does not prevent concurrent duplicate execution.
- Require idempotency key for
POST /orders - TTL aligned to client retry window
- Combine with optimistic locking for updates
Common mistakes
- Setting
requireKey=falseon critical writes - Reusing same key across different payloads intentionally
- Too short TTL for real retry behavior
Validation checklist
- second identical request does not call handler
- conflicting payload returns
409 - replayed response has marker header
Request scope and migration (1.1.1)
The route component in the default key and fingerprint contains the router namespace, template, concrete request path and separately captured URL parameters. Identity and canonical payload parameters remain scoped; a query/body id cannot hide the URL target. Custom key/fingerprint resolvers must preserve equivalent isolation.
Existing default idempotency records cannot safely translate and have no legacy replay fallback. Pause writers/retries, drain requests, reconcile uncertain business operations and retire old retries over the full client retry horizon before switching every worker together. Waiting for TTL alone or clearing records is insufficient. Rollback needs the same coordination; a custom key alone still uses the changed default fingerprint. See the complete migration procedure.
After an uncertain failure, reconcile before retrying with any key, even after reservation expiry. Keep business-level deduplication for irreversible effects.