# idempotency middleware

one layer is not enough. the lock holds the duplicate that arrives now, the
unique index holds the one that arrives later, and they complement each other.

this note continues [idempotency](../../architecture/idempotency.md) and i am
assuming you have read it. that one ends by showing the window a check-then-write
model leaves open, and here i will show you how we close it using laravel as the
example.

## two races, not one

simultaneous: two requests sent at the same instant. both check, both find
nothing, and then both process. this is the stacked listener firing four `POST`
in the same millisecond that i mentioned in the
[other note](../../architecture/idempotency.md).

not simultaneous: the repeat arrives seconds or hours later, or after the cache
has been flushed. this is the retry case, very common in apis that receive
requests from systems with queues, like a system that processes withdrawals:
there is a queue of withdrawals to be processed, and on each one a request goes
out to a partner bank's api to authorize sending the transfer.

and with that we need to keep the following in mind: guarding the record is not
guarding the effect. the unique index stops the second row from being written,
but it does not stop the card from being charged twice. by the time the insert
fails the money is already gone, except now you do not even have a second row
telling you about it, which is worse.

## before the lock

we start our middleware by requiring the header:

```php
$key = $request->header('Idempotency-Key');

if (! is_string($key) || $key === '') {
    return response()->json(
        ['message' => 'Idempotency-Key header is required.'], 422
    );
}
```

then we build the cache key and check it:

```php
$cacheKey = 'idempotency:'.sha1(
    $request->method().'|'.$request->path().'|'.$key
);
$requestHash = hash('sha256', $request->getContent());

if (($stored = $this->stored($cacheKey)) !== null) {
    return $this->replayOrReject($stored, $requestHash);
}
```

notice that we check this before any lock. it is the fast path, and it is where
we catch most of the repeats: the timeout happened, the first request already
finished and stored, and the second one just reads and returns.

there is a detail here: if every request had to acquire a lock in redis, you
would serialize the entire endpoint to solve a case that almost never happens.
the lock is the exception path, it only comes into play when there is a real
contention.

## same key, different body

the hash is computed from the raw content, before any parsing:

```php
$requestHash = hash('sha256', $request->getContent());
```

`getContent()` returns the bytes as they arrived. this matters more than it
looks: serializing an object is not canonical, so `{"a":1,"b":2}` and
`{"b":2,"a":1}` are the same payload and would produce different hashes if the
hash came from the already parsed object. hashing the bytes off the wire avoids
rejecting a client that merely ordered its keys differently.

every repeat goes through this check before being returned:

```php
private function replayOrReject(
    array $stored,
    string $requestHash
): Response {
    if (($stored['request_hash'] ?? null) !== $requestHash) {
        return response()->json([
            'message' => 'This Idempotency-Key was already used'
                .' with a different request body.',
        ], 422);
    }

    $this->replayOrReject($stored, $requestHash);
}
```

with that, a repeat stops having two outcomes and starts having three: no record,
process. record exists and the hash matches, replay. record exists and the hash
does not match, 422.

the third case is the one that was missing. without it, the same key sent with a
different payload returned the old response silently, and the second request
vanished without any error.

## the lock

```php
return Cache::lock($cacheKey.':lock', 10)->block(
    10,
    fn (): Response => $this->process($request, $next, $cacheKey, $ttl),
);
```

`block(10)` means: wait up to ten seconds for the lock instead of failing right
away. the first request wins it and runs, the others queue behind.

the part that is easy to get wrong is inside `process`:

```php
// Another concurrent request may have finished
// while we waited on the lock.
if (($stored = $this->stored($cacheKey)) !== null) {
    return $this->replayOrReject($stored, $requestHash);
}
```

the cache is checked again, after acquiring the lock. without that second check
the lock buys nothing: request B waits politely for A to finish, and then
processes anyway, because the only check it made happened before it started
waiting.

if the ten seconds pass and the lock never comes:

```php
} catch (LockTimeoutException) {
    $stored = $this->stored($cacheKey);

    return $stored !== null
        ? $this->replay($stored)
        : response()->json([...], 409);
}
```

we answer 409 and not 500, because in fact nothing failed. there is a request
with that key still in flight, and the honest answer is "come back in a moment",
which is a conflict and not an error.

## the unique index

```php
// Client-supplied idempotency key. Unique so a replayed create can
// never insert a second payment (Postgres allows multiple NULLs).
$table->string('idempotency_key')->nullable()->unique();
```

the cache is memory. it gets flushed, redis restarts, and the TTL expires after
24 hours by default:

```php
'idempotency_ttl' => (int) env('IDEMPOTENCY_TTL', 86400),
```

past that point the lock and the stored response are gone, and a very late retry
would sail straight through. the index is what survives all of it, because it
lives in the same database as the payment.

the `nullable()` matters more than it looks. postgres allows any number of
`NULL` values in a unique column, so payments created through paths that do not
require a key do not collide with each other.

## why one alone is not enough

| | catches | misses |
| --- | --- | --- |
| lock | duplicates at the same time | anything after a flush or the TTL |
| unique index | the duplicate row, forever | the duplicate work already in progress |

a lock without an index is safe until someone flushes the cache. an index
without a lock lets both requests charge the card and only fails the second
insert, which leaves you with one payment row and two charges (you do not want
that).

## only successful responses are replayable

```php
// Only successful responses are replayable; failures may be retried.
if ($response->getStatusCode() < 300) {
    Cache::put($cacheKey, [
        'status' => $response->getStatusCode(),
        'body' => $response->getContent(),
        'request_hash' => $requestHash,
    ], $ttl);
}
```

this one is counterintuitive enough to deserve a section of its own. if you
stored a 500, that key would replay the failure forever. a temporary outage
would become permanent for that customer, and the only way out would be
generating a new key, which the client has no reason to generate because it
believes it is retrying correctly (and it is).

the replay is marked, so the client can tell:

```php
->header('Idempotent-Replayed', 'true')
```

and the test checks exactly that, along with the fact that no second payment row
shows up:

```php
$second->assertHeader('Idempotent-Replayed', 'true');

expect($second->json('data.id'))->toBe($first->json('data.id'))
    ->and(Payment::query()->count())->toBe(1);
```

## in the end

the whole middleware fits into three guarantees: the key is required, every exit
goes through the hash check, and nothing is replayed without the lock or the
index having made sure only one effect happened.

the rest is implementation detail. swap `Cache::lock` for any distributed lock
and the unique index for any equivalent constraint, and the design still stands.
that is why i put the concept in [another note](../../architecture/idempotency.md),
here i used laravel only as the instrument.

---

the code for this project is at
[laravel-payment-gateway](https://github.com/RRaniere/laravel-payment-gateway).
