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 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.
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:
$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:
$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:
$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:
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
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:
// 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:
} 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
// 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:
'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
// 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:
->header('Idempotent-Replayed', 'true')
and the test checks exactly that, along with the fact that no second payment row shows up:
$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,
here i used laravel only as the instrument.
the code for this project is at laravel-payment-gateway.