$npx -y skills add Lonsdale201/wp-agent-skills --skill br-idempotencyConfigure better-route 1.1 replay-cache idempotency with IdempotencyMiddleware and Idempotency-Key. Use for ArrayIdempotencyStore, TransientIdempotencyStore, WpdbIdempotencyStore, installSchema, identity-aware canonical keys, body fingerprints, key conflicts, replay headers, key
| 1 | # better-route: replay-cache idempotency |
| 2 | |
| 3 | Use `IdempotencyMiddleware` when replaying a completed response is useful but simultaneous duplicate execution is acceptable or impossible at another layer. It checks the store, invokes the handler, and stores only after completion; it does not reserve first. |
| 4 | |
| 5 | For payments, order creation, subscriptions, account provisioning, or any irreversible side effect, use `br-atomic-idempotency` instead. |
| 6 | |
| 7 | ## Production setup |
| 8 | |
| 9 | ```php |
| 10 | use BetterRoute\Middleware\Write\IdempotencyMiddleware; |
| 11 | use BetterRoute\Middleware\Write\WpdbIdempotencyStore; |
| 12 | |
| 13 | register_activation_hook(__FILE__, static function (): void { |
| 14 | (new WpdbIdempotencyStore())->installSchema(); |
| 15 | }); |
| 16 | |
| 17 | $idempotency = new IdempotencyMiddleware( |
| 18 | store: new WpdbIdempotencyStore(), |
| 19 | ttlSeconds: 600, |
| 20 | requireKey: true, |
| 21 | ); |
| 22 | |
| 23 | $router->post('/drafts', $handler) |
| 24 | ->protectedByMiddleware('bearerAuth') |
| 25 | ->middleware([$auth, $idempotency]); |
| 26 | ``` |
| 27 | |
| 28 | Store choices: |
| 29 | |
| 30 | - Use `WpdbIdempotencyStore` as the durable production default and call `installSchema()`. |
| 31 | - Use `TransientIdempotencyStore` only when transient lifecycle/cache flush semantics are acceptable. |
| 32 | - Use `ArrayIdempotencyStore` only for tests or one-process checks; normal PHP requests do not share its state. |
| 33 | |
| 34 | Cross-database wpdb table names containing `.` are rejected. |
| 35 | |
| 36 | ## 1.1 request contract |
| 37 | |
| 38 | The middleware applies to `POST`, `PUT`, `PATCH`, and `DELETE` by default. Override `methods` if the route needs a different set. |
| 39 | |
| 40 | `Idempotency-Key` must be printable ASCII and no longer than `maxKeyLength` (default 200). Missing required keys return `400 idempotency_key_required`; malformed keys return `400 idempotency_key_invalid`. |
| 41 | |
| 42 | The default storage key deeply canonicalizes route, authenticated/native WordPress identity, and client key. The default fingerprint deeply canonicalizes route, method, identity, JSON/body params, and request params. Reordered associative keys remain equivalent; ordered lists remain order-sensitive. |
| 43 | |
| 44 | On retry: |
| 45 | |
| 46 | - same storage key + same fingerprint replays the stored response and adds `Idempotency-Replayed: true`; |
| 47 | - same storage key + changed fingerprint returns `409 idempotency_conflict`; |
| 48 | - two concurrent first requests can both execute because classic middleware has no reservation. |
| 49 | |
| 50 | ## Response behavior |
| 51 | |
| 52 | `WP_REST_Response` is normalized to a Better Route response before storage so status, data, and string headers can be replayed safely. Returned `WP_Error` is not stored. Thrown exceptions are not stored because the middleware never reaches its store call. |
| 53 | |
| 54 | A returned `Response` with a 4xx/5xx status is still a completed return value and can be cached. Decide whether that is appropriate; throw for transient failures or use a custom policy/store if only selected statuses should persist. |
| 55 | |
| 56 | For wpdb replay, return arrays/scalars or `BetterRoute\Http\Response`; arbitrary domain objects are not a supported persisted response contract. |
| 57 | |
| 58 | ## Custom scope |
| 59 | |
| 60 | ```php |
| 61 | $idempotency = new IdempotencyMiddleware( |
| 62 | store: $store, |
| 63 | requireKey: true, |
| 64 | keyResolver: static fn ($context, string $clientKey): string => |
| 65 | 'tenant:' . current_tenant_id() . ':' . $clientKey, |
| 66 | fingerprintResolver: static fn ($context): string => |
| 67 | hash('sha256', canonical_domain_payload($context->request)), |
| 68 | ); |
| 69 | ``` |
| 70 | |
| 71 | Custom resolvers own collision resistance and canonicalization. Include every security/business dimension that distinguishes operations; do not concatenate attacker-controlled fields with an ambiguous delimiter. |
| 72 | |
| 73 | ## Review checklist |
| 74 | |
| 75 | - Choose atomic middleware when simultaneous duplicates would be unsafe. |
| 76 | - Run auth before idempotency; native WP identity is a fallback, not a replacement for route auth. |
| 77 | - Install the wpdb schema before traffic. |
| 78 | - Choose a TTL that covers real client retry delays. |
| 79 | - Keep keys bounded and generate one key per logical operation. |
| 80 | - Verify identical replay, changed-payload conflict, malformed key, missing key, and two concurrent first requests. |
| 81 | - Plan expired-row cleanup for high-volume wpdb tables; on-access expiry alone does not purge every unused old key. |
| 82 | |
| 83 | ## Related skills |
| 84 | |
| 85 | - Use `br-atomic-idempotency` for reservation-before-handler semantics. |
| 86 | - Use `br-routes` for access intent and |