Install
$ agentstack add skill-lonsdale201-wp-agent-skills-br-idempotency ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
better-route: Idempotent writes
For developers protecting POST / PUT / PATCH endpoints from duplicate writes — payment captures, order creation, anything that should run once even if the client retries due to network errors. The middleware honors the Idempotency-Key request header pattern (Stripe / Visa Acceptance / many SaaS APIs use the same convention).
Misconception this skill corrects
> "Classic IdempotencyMiddleware is enough to prevent concurrent double execution."
Not for high-side-effect endpoints. Classic IdempotencyMiddleware checks the store, runs the handler, then stores the response. Two identical requests arriving at the same time can both see an empty store and both execute. For payment/order/subscription/account-like writes, use br-atomic-idempotency and AtomicIdempotencyMiddleware, which reserves before handler execution.
> "I'll use ArrayIdempotencyStore since it's the default and simplest."
ArrayIdempotencyStore keeps state in PHP memory FOR ONE REQUEST ONLY. The next request starts with an empty store. So duplicate detection works only within a single PHP process — not across requests, which is the whole point. It's a test fixture, not a production store.
The production options:
| Store | Survives | When to pick | |---|---|---| | ArrayIdempotencyStore | Single process | Unit tests only. | | TransientIdempotencyStore | Until cache flush | OK if your object cache is persistent (Redis); risky on standard hosts where transients live in wp_options and a cache plugin can clear them. | | WpdbIdempotencyStore | DB-persistent | Production default. Survives object-cache flushes and PHP restarts. |
For concurrent duplicate prevention, the production option is WpdbAtomicIdempotencyStore with AtomicIdempotencyMiddleware.
Other AI-prone misconceptions:
- "I'll set up
WpdbIdempotencyStoreonce and it just works." Wrong — callinstallSchema()once on plugin activation. Without the table, everyINSERT/SELECTfails. Verified at [WpdbIdempotencyStore.php:76](WpdbIdempotencyStore.php). - "I can use a cross-database table by passing
'other_db.idempotency'." Wrong — the table-name guard at [WpdbIdempotencyStore.php:104-110](WpdbIdempotencyStore.php) rejects names containing.. This prevents accidental cross-DB writes; if you genuinely need cross-DB, write a custom store. - "Default v0.3.0 idempotency key is global." Wrong — it's identity-aware:
{provider}:user:{userId}→{provider}:sub:{subject}→'guest'. Two different users sending the sameIdempotency-Keyget separate buckets, which is what you want.
When to use this skill
Trigger when ANY of the following is true:
- The diff instantiates
IdempotencyMiddleware,WpdbIdempotencyStore,TransientIdempotencyStore. - The route needs retry replay but not pre-handler reservation.
- Setting up a payment / billing endpoint.
- The user asks "how do I prevent duplicate orders" / "Stripe-style idempotency".
- Plugin-activation code that needs
installSchema().
Workflow
1. Choose the store
use \BetterRoute\Middleware\Write\IdempotencyMiddleware;
use \BetterRoute\Middleware\Write\WpdbIdempotencyStore;
// Plugin activation: ensure the table exists
register_activation_hook(__FILE__, function (): void {
(new WpdbIdempotencyStore())->installSchema();
});
// Runtime: instantiate the store + middleware
$store = new WpdbIdempotencyStore();
// or with a custom table name:
$store = new WpdbIdempotencyStore(table: 'myapp_idempotency');
$middleware = new IdempotencyMiddleware(
store: $store,
ttlSeconds: 600, // 10-minute window
requireKey: true, // reject requests without Idempotency-Key
);
installSchema() runs CREATE TABLE IF NOT EXISTS for the idempotency table. Safe to call repeatedly. Best place is plugin activation (runs once per install), but you can also call it from a one-off setup script.
2. Apply to write routes
$router->post('/payments', $createPaymentHandler)
->protectedByMiddleware('jwt')
->middleware([$jwt, $middleware]);
The middleware runs only for POST / PUT / PATCH. GET / DELETE / HEAD pass through unchanged.
3. Client-side header
POST /wp-json/myapp/v1/payments HTTP/1.1
Authorization: Bearer ...
Idempotency-Key: 7c3a9b2e-4f81-4a23-8d1e-9c2b5f8e1d4a
Content-Type: application/json
{"amount": 1000, "currency": "USD"}
The client generates a unique key per logical operation (UUID v4 is canonical). On the FIRST request, the middleware:
- Reads
Idempotency-Key. - Computes the storage key:
{routePath}:{idempotencyKey}:{identityKey}. - Calls
$store->get($storageKey)— returns null (nothing cached yet). - Runs
$next($context)→ handler executes the payment. - Stores
$responseunder the storage key withttlSecondsTTL.
On the SECOND request with the SAME key:
- Reads
Idempotency-Key(same value). - Computes the same storage key.
$store->get(...)returns the cached response.- Middleware returns the cached response WITHOUT calling
$next— handler doesn't run twice.
4. The TTL window
new IdempotencyMiddleware($store, ttlSeconds: 600); // 10 minutes
Verified at [IdempotencyMiddleware.php:31](IdempotencyMiddleware.php) — default is 300 seconds (5 minutes). Pick the TTL based on:
- How long do clients reasonably retry? Network hiccups → seconds. User-driven retries → minutes. Background job retries → hours.
- Storage cost. Long TTLs accumulate idempotency entries.
- Replay-attack window. Anyone with the cached response can replay it within the TTL — usually fine, since the response is the EXPECTED output anyway.
For payment endpoints, 24h+ is common (Stripe uses 24h). For low-stakes writes, 5-10 minutes is enough.
5. requireKey vs optional
// Hard requirement — reject anything without Idempotency-Key
new IdempotencyMiddleware($store, requireKey: true);
// Optional — apply idempotency when key is present, pass through otherwise
new IdempotencyMiddleware($store, requireKey: false);
requireKey: true ([IdempotencyMiddleware.php:52-53](IdempotencyMiddleware.php)) throws ApiException('Idempotency key is required.', 400, 'idempotency_key_required') when the header is missing. Use for high-stakes writes (payments) where a missing key is a client bug.
requireKey: false (default) silently runs the handler when no key is present. Use for backwards-compatible adoption — clients that don't send the header still work; clients that do get duplicate protection.
6. Custom keyResolver
$middleware = new IdempotencyMiddleware(
store: $store,
ttlSeconds: 600,
requireKey: true,
keyResolver: function ($request, string $idempotencyKey) {
// Bucket by tenant ID instead of user ID
return 'tenant:' . get_current_tenant_id() . ':' . $idempotencyKey;
},
);
Use when the default identity-aware key (user ID / sub / guest) doesn't match your bucketing strategy — e.g. multi-tenant SaaS where the same user can act on behalf of multiple orgs.
7. Per-route TTL via separate middleware
$paymentIdempotency = new IdempotencyMiddleware($store, ttlSeconds: 86400); // 24h for payments
$generalIdempotency = new IdempotencyMiddleware($store, ttlSeconds: 600); // 10m elsewhere
$router->post('/payments', $handler)
->protectedByMiddleware('jwt')
->middleware([$jwt, $paymentIdempotency]);
$router->post('/comments', $handler)
->protectedByMiddleware('jwt')
->middleware([$jwt, $generalIdempotency]);
One store, two middleware instances with different TTL configs.
Critical rules
WpdbIdempotencyStore::installSchema()once on plugin activation. Without the table, every operation fails.- Cross-database table names rejected — table parameter must NOT contain
.. Verified at [WpdbIdempotencyStore.php:104-110](WpdbIdempotencyStore.php). For cross-DB, write a custom store implementingIdempotencyStoreInterface. ArrayIdempotencyStoreis for tests only — single-process, lost on next request.- Apply middleware to write routes only. GET / HEAD / DELETE pass through unchanged but still cost the middleware overhead.
requireKey: truerejects with400 idempotency_key_requiredwhen header missing. Use for high-stakes writes.- v0.3.0 default key is identity-aware. Same
Idempotency-Keyfrom different users → different buckets. Pre-v0.3 was global; passkeyResolverto preserve old behavior. - TTL pruning is on-access. Expired rows aren't garbage-collected by a cron; they're removed when read after expiration. So a row idle for years just sits there until someone tries the same key.
- Cached response includes the original status code. A 201 is returned 201 on retry; a 4xx error on the first request is also "remembered" and returned again. Decide if that's what you want for failed-but-deterministic flows.
- Combine with auth middleware (auth FIRST). Otherwise the idempotency key resolver can't distinguish users — everything keys on
'guest'.
Common mistakes
// WRONG — ArrayIdempotencyStore in production
$store = new ArrayIdempotencyStore(); // 🔴 single-process; not what you want
$middleware = new IdempotencyMiddleware($store);
// RIGHT
$store = new WpdbIdempotencyStore();
// WRONG — forgetting installSchema
$store = new WpdbIdempotencyStore();
$middleware = new IdempotencyMiddleware($store);
// First request fails: table doesn't exist.
// RIGHT
register_activation_hook(__FILE__, function (): void {
(new WpdbIdempotencyStore())->installSchema();
});
// WRONG — cross-DB table
$store = new WpdbIdempotencyStore(table: 'analytics_db.idempotency'); // 🔴 rejected
// RIGHT — same DB
$store = new WpdbIdempotencyStore(table: 'better_route_idempotency');
// WRONG — requireKey: true on backwards-compat upgrade
new IdempotencyMiddleware($store, requireKey: true);
// → existing clients (which don't send Idempotency-Key) suddenly fail with 400.
// RIGHT — phase in
// Stage 1 (release): requireKey: false; clients gradually adopt the header.
new IdempotencyMiddleware($store, requireKey: false);
// Stage 2 (after observed adoption): requireKey: true.
new IdempotencyMiddleware($store, requireKey: true);
// WRONG — middleware order
->middleware([$idempotency, $jwt])
// Idempotency keys on 'guest' for everyone (auth hasn't run); two different users with the
// same Idempotency-Key collide.
// RIGHT — auth first
->middleware([$jwt, $idempotency])
// WRONG — applying to GET endpoints "for safety"
$router->get('/orders', $listOrders)->middleware([$idempotency]);
// Middleware skips GET (correct), so this is no-op overhead.
// RIGHT — write methods only
$router->post('/orders', $createOrder)->middleware([$idempotency]);
// WRONG — short TTL on payment endpoint
new IdempotencyMiddleware($store, ttlSeconds: 60);
// Client retries after a 5-minute network outage → middleware no longer remembers,
// runs handler again, charges card twice.
// RIGHT — generous TTL for payments
new IdempotencyMiddleware($store, ttlSeconds: 86400); // 24h
// WRONG — assuming the cached error response WON'T be cached
// Client sends bad request, gets 400. Retries with same key, gets the cached 400 — but in
// the meantime they fixed the request. Cached response returns the OLD 400.
// RIGHT — accept that idempotency caches whatever happened. If you want fresh attempts after
// the client fixes their bug, the client should rotate the Idempotency-Key for each new attempt.
// Or only cache 2xx via a custom store implementation.
// WRONG — no garbage collection plan for the table
// 24h TTLs + 1000 RPS → 86.4M rows after 24h, never auto-cleaned (pruned on access only).
// RIGHT — schedule a cron to delete expired rows:
add_action('myapp_idempotency_cleanup', function () {
global $wpdb;
$wpdb->query("DELETE FROM {$wpdb->prefix}better_route_idempotency WHERE expires_at protectedByMiddleware()` or `->permission()`.
- Run **`br-auth-middleware`** for the auth middleware that should run BEFORE idempotency.
- Run **`br-rate-limiting`** for combining throttle + idempotency on the same endpoint (orthogonal concerns).
- Run **`br-error-contract`** for the `400 idempotency_key_required` and `409 idempotency_conflict` envelope shapes.
- Run **`br-atomic-idempotency`** for high-side-effect writes where concurrent duplicate execution must be blocked before the handler starts.
## What this skill does NOT cover
- Idempotency-Key generation on the client side. UUID v4 is the convention; any unique string works.
- Atomic reservation semantics. Use `AtomicIdempotencyMiddleware` / `br-atomic-idempotency` for that.
- Multi-region replication of the idempotency table. WP isn't multi-region native; replicate via DB-level mechanisms.
- Cleanup automation beyond on-access pruning. Schedule a WP cron if needed.
- Stripe-style idempotency-replay headers (`Idempotent-Replayed: true`). Better-route returns the cached response indistinguishably from a fresh response. Add a custom header if your client cares.
## References
- IdempotencyMiddleware: [libraries/better-route/src/Middleware/Write/IdempotencyMiddleware.php:29-33](IdempotencyMiddleware.php) — `__construct(store, ttlSeconds: 300, requireKey: false)`. Required-key throw at line 52-53 (`idempotency_key_required`).
- WpdbIdempotencyStore: [libraries/better-route/src/Middleware/Write/WpdbIdempotencyStore.php:11-15](WpdbIdempotencyStore.php) — `__construct(table: 'better_route_idempotency', prefix: null)`.
- Schema install: [WpdbIdempotencyStore.php:76+](WpdbIdempotencyStore.php) — `installSchema()` runs `CREATE TABLE IF NOT EXISTS` with TTL index.
- Cross-DB rejection: [WpdbIdempotencyStore.php:104-110](WpdbIdempotencyStore.php) — `tableName()` throws on `str_contains($table, '.')`.
- IdempotencyStoreInterface: [libraries/better-route/src/Middleware/Write/IdempotencyStoreInterface.php](IdempotencyStoreInterface.php) — `get(string): mixed`, `set(string, mixed, int)`, `delete(string)` for custom stores.
- Other stores: [libraries/better-route/src/Middleware/Write/ArrayIdempotencyStore.php](ArrayIdempotencyStore.php), [libraries/better-route/src/Middleware/Write/TransientIdempotencyStore.php](TransientIdempotencyStore.php).
## Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- **Author:** [Lonsdale201](https://github.com/Lonsdale201)
- **Source:** [Lonsdale201/wp-agent-skills](https://github.com/Lonsdale201/wp-agent-skills)
- **License:** MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.