Idempotency - viames/pair GitHub Wiki
Pair framework: Idempotency
Pair\Api\Idempotency prevents duplicate execution of mutating endpoints.
Implementation details:
- cache-backed storage through
Pair\Cache\CacheStore - default file-backed storage under
TEMP_PATH/idempotency - key scoped by custom scope string + client idempotency key
- request hash check (
method + URI + body) - replay of previously stored response
Required request headers
Client can send one of:
Idempotency-KeyX-Idempotency-Key
Typical usage pattern
use Pair\Api\Idempotency;
use Pair\Api\ApiResponse;
Idempotency::respondIfDuplicate($this->request, 'orders:create');
$result = ['orderId' => 123, 'saved' => true];
Idempotency::storeResponse($this->request, 'orders:create', $result, 201);
ApiResponse::respond($result, 201);
Main methods
duplicateResponse(Request $request, string $scope, int $ttlSeconds = 86400): ResponseInterface|null
Behavior:
- no key -> returns
null - existing key with same hash and completed response -> returns an explicit replay
JsonResponse - existing key with same hash and status
processing-> returns an explicitCONFLICTerror response - existing key with different request hash -> returns an explicit
CONFLICTerror response - new key -> writes
processinglock and returnsnull
respondIfDuplicate(Request $request, string $scope, int $ttlSeconds = 86400): bool
Behavior:
- legacy bridge over
duplicateResponse(...) - sends the explicit replay/conflict response immediately when one is returned
- returns
falseafter sending an immediate replay/conflict response - otherwise returns
trueand request continues
For new explicit v4 actions, prefer duplicateResponse(...) and return the response object. If you keep using respondIfDuplicate(...), check the boolean result before continuing:
if (!Idempotency::respondIfDuplicate($this->request, 'orders:create')) {
return;
}
storeResponse(Request $request, string $scope, mixed $data, int $httpCode = 200, int $ttlSeconds = 86400): bool
Stores canonical response for future retries with same key.
clearProcessing(Request $request, string $scope): bool
Clears the processing lock, useful in explicit rollback/error flows.
setStore(CacheStore $store): void
Configures the idempotency cache store for the current PHP process.
use Pair\Api\Idempotency;
use Pair\Cache\RedisCacheStore;
Idempotency::setStore(new RedisCacheStore($redis, 'pair:idempotency:'));
clearStore(): void
Resets the configured store so Pair resolves the default file-backed store again.
Best practices
- Use stable scope names (
orders:create,billing:payInvoice). - Always call
storeResponse(...)before sending or returning the final success response. - Keep TTL aligned with retry windows used by clients/offline queues.
- Use a shared backend such as Redis when idempotency must work across multiple application nodes.
Frequent usage recipes
Full safe flow with rollback cleanup
use Pair\Api\Idempotency;
use Pair\Api\ApiResponse;
if ($response = Idempotency::duplicateResponse($this->request, 'orders:create', 86400)) {
return $response;
}
try {
$payload = $this->request->validate([
'customerId' => 'required|int',
'amount' => 'required|numeric|min:0.01',
]);
$result = [
'orderId' => 2241,
'customerId' => (int)$payload['customerId'],
];
Idempotency::storeResponse($this->request, 'orders:create', $result, 201, 86400);
return ApiResponse::jsonResponse($result, 201);
} catch (\Throwable $e) {
Idempotency::clearProcessing($this->request, 'orders:create');
throw $e;
}
Same key, different payload protection
If the same idempotency key is reused with a different body/URI/method hash, Pair returns CONFLICT. This protects against accidental key reuse bugs in clients.
Optional behavior without key
// no idempotency header -> method returns true and request continues normally
Idempotency::respondIfDuplicate($this->request, 'payments:create');
Common pitfalls
- Forgetting
clearProcessing()in exception paths. - Using generic scopes that collide across operations.
- Storing huge response payloads unnecessarily in the selected cache backend.
See also: API, Cache, Request, ApiResponse, PWA.