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-Key
  • X-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 explicit CONFLICT error response
  • existing key with different request hash -> returns an explicit CONFLICT error response
  • new key -> writes processing lock and returns null

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 false after sending an immediate replay/conflict response
  • otherwise returns true and 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.