$npx -y skills add Lonsdale201/wp-agent-skills --skill br-atomic-idempotencyConfigure better-route 1.1 AtomicIdempotencyMiddleware for side-effectful POST, PUT, PATCH, or DELETE routes where concurrent duplicate execution must be prevented. Use for WpdbAtomicIdempotencyStore, lease-aware reservations, reservation_token schema migration, Idempotency-Key v
| 1 | # better-route: atomic idempotency |
| 2 | |
| 3 | Use atomic idempotency for writes whose side effect must not execute twice under concurrent retries. It reserves before invoking the handler; classic `IdempotencyMiddleware` only stores after completion. |
| 4 | |
| 5 | ## Production setup |
| 6 | |
| 7 | ```php |
| 8 | use BetterRoute\Middleware\Write\AtomicIdempotencyMiddleware; |
| 9 | use BetterRoute\Middleware\Write\WpdbAtomicIdempotencyStore; |
| 10 | |
| 11 | register_activation_hook(__FILE__, static function (): void { |
| 12 | (new WpdbAtomicIdempotencyStore())->installSchema(); |
| 13 | }); |
| 14 | |
| 15 | $atomic = new AtomicIdempotencyMiddleware( |
| 16 | store: new WpdbAtomicIdempotencyStore(), |
| 17 | ttlSeconds: 86400, |
| 18 | requireKey: true, |
| 19 | ); |
| 20 | |
| 21 | $router->post('/payments', $handler) |
| 22 | ->protectedByMiddleware('bearerAuth') |
| 23 | ->middleware([$auth, $atomic]); |
| 24 | ``` |
| 25 | |
| 26 | Run `installSchema()` for both new installs and 1.0 upgrades. The 1.1 table has a per-reservation `reservation_token`; the installer creates or migrates that column. |
| 27 | |
| 28 | Use `ArrayAtomicIdempotencyStore` only in tests or non-WordPress single-process checks. |
| 29 | |
| 30 | ## Behavior |
| 31 | |
| 32 | - First request reserves the canonical route + identity + client key and runs the handler. |
| 33 | - Same key and fingerprint while reserved returns `409 idempotency_in_progress`. |
| 34 | - Same key with a different fingerprint returns `409 idempotency_conflict`. |
| 35 | - Completed identical retry replays status/body/headers and adds `Idempotency-Replayed: true`. |
| 36 | - Missing key returns `400 idempotency_key_required` when required. |
| 37 | - Invalid, non-printable, or overlong keys return `400 idempotency_key_invalid`; default maximum length is 200. |
| 38 | |
| 39 | The default fingerprint deeply canonicalizes route, method, identity, and request params. Associative key order does not change it; list order remains meaningful. Native logged-in WordPress users, auth middleware identities, and HMAC key identities scope defaults safely. |
| 40 | |
| 41 | ## Failure semantics |
| 42 | |
| 43 | In 1.1 `releaseOnThrowable` defaults to `false`. A thrown exception can occur after an irreversible external side effect but before the response is recorded, so the reservation remains `in_progress` until TTL rather than allowing a dangerous automatic retry. |
| 44 | |
| 45 | Set `releaseOnThrowable: true` only if the complete operation is transactional/rollback-safe or the handler is known to fail before any side effect: |
| 46 | |
| 47 | ```php |
| 48 | new AtomicIdempotencyMiddleware( |
| 49 | store: $store, |
| 50 | releaseOnThrowable: true, |
| 51 | ); |
| 52 | ``` |
| 53 | |
| 54 | Returned `WP_Error` values are not serialized and leave the reservation uncertain. They are normalized after the middleware pipeline. |
| 55 | |
| 56 | ## Stored response contract |
| 57 | |
| 58 | The wpdb store serializes a data-only envelope with `allowed_classes => false`: |
| 59 | |
| 60 | - arrays, scalars, and null are supported; |
| 61 | - `BetterRoute\Http\Response` is decomposed into body/status/string headers; |
| 62 | - `WP_REST_Response` is normalized into the same safe Response form; |
| 63 | - arbitrary objects/resources inside the body are rejected. |
| 64 | |
| 65 | Do not return domain objects from idempotent handlers; map them to data first. |
| 66 | |
| 67 | Lease-aware completion and release include the unpredictable reservation token. A stale request therefore cannot complete or delete a newer reservation that reused the same key after expiry. |
| 68 | |
| 69 | ## WooCommerce registrar |
| 70 | |
| 71 | When Woo idempotency is enabled in WordPress, `WooRouteRegistrar` uses atomic idempotency across orders, products, customers, and coupons. Without a custom store it installs/migrates the wpdb store once per schema version. Installation failure is surfaced; it does not silently degrade to an array store. |
| 72 | |
| 73 | ```php |
| 74 | 'idempotency' => [ |
| 75 | 'enabled' => true, |
| 76 | 'requireKey' => true, |
| 77 | 'ttlSeconds' => 86400, |
| 78 | // 'store' => $customAtomicStore, |
| 79 | ], |
| 80 | ``` |
| 81 | |
| 82 | Any custom Woo store must implement `AtomicIdempotencyStoreInterface`. |
| 83 | |
| 84 | ## Review checklist |
| 85 | |
| 86 | - Put authentication before atomic idempotency. |
| 87 | - Install/migrate the wpdb schema before serving traffic. |
| 88 | - Choose TTL for the longest realistic uncertain/retry window. |
| 89 | - Keep the default fail-closed `releaseOnThrowable: false` unless retry safety is proven. |
| 90 | - Return data-only responses. |
| 91 | - Load-test two simultaneous identical requests; exactly one handler may run. |
| 92 | - Test same key with a changed body and verify `idempotency_conflict`. |
| 93 | - Use a custom key/fingerprint resolver only when tenant/domain scope cannot be expressed b |