Повтори в платіжній системі неминучі: подвійний клік, таймаут мережі з автоматичним retry, redelivery з черги, повторний вебхук провайдера. Ідемпотентність означає, що будь-який із цих повторів дає той самий результат, що й перший виклик, без другого списання.
Базовий механізм — idempotency key. Клієнт генерує унікальний ключ на бізнес-операцію, а не на HTTP-запит, і передає його заголовком. Сервер атомарно резервує ключ у таблиці з унікальним індексом, виконує операцію й зберігає результат разом із ключем. Повторний запит із тим самим ключем отримує збережену відповідь. Той самий ключ передається платіжному провайдеру, тому навіть падіння між викликом провайдера і збереженням результату не створює другого платежу.
Гонку закриває саме унікальний індекс, а не перевірка в коді: два одночасні запити проходять if (! exists) разом, але лише один пройде INSERT. Стан ключа має проміжне значення processing, щоб повтор під час виконання отримав 409 з Retry-After, а не чужий результат і не другу спробу. Ключ із тим самим значенням, але іншим тілом запиту повертає 422.
Вебхуки провайдера обробляються за тим самим принципом: дедуплікація по event id через унікальний індекс і перевірка стану агрегату перед кожною дією, бо доставка гарантована щонайменше один раз, а не рівно один. Ключі мають час життя, зазвичай від доби до трьох, і чистяться фоновим процесом.
// CREATE TABLE idempotency_keys (key text, client_id bigint, request_hash text,
// status text, response_code int, response_body jsonb, created_at timestamptz,
// PRIMARY KEY (client_id, key));
final class ChargeAction
{
public function __invoke(ChargeRequest $request): JsonResponse
{
$key = $request->header('Idempotency-Key') ?? abort(400, 'Idempotency-Key required');
$hash = hash('sha256', $request->getContent());
// 1. Атомарне резервування ключа: унікальний індекс закриває гонку
try {
IdempotencyKey::create(['key' => $key, 'client_id' => $request->clientId(), 'request_hash' => $hash, 'status' => 'processing']);
} catch (UniqueConstraintViolationException) {
$existing = IdempotencyKey::where('client_id', $request->clientId())->where('key', $key)->firstOrFail();
if ($existing->request_hash !== $hash) {
abort(422, 'Idempotency-Key reused with a different payload');
}
if ($existing->status === 'processing') {
return response()->json(['status' => 'processing'], 409)->header('Retry-After', '2');
}
return response()->json($existing->response_body, $existing->response_code); // збережений результат
}
// 2. Сама операція: провайдер отримує той самий ключ, повтор не створить другий платіж
$charge = $this->gateway->charge($request->amount(), $request->card(), idempotencyKey: $key);
// 3. Результат зберігається разом з ключем в одній транзакції
DB::transaction(function () use ($key, $request, $charge) {
Payment::create(['charge_id' => $charge->id, 'amount' => $request->amount()]);
IdempotencyKey::where('client_id', $request->clientId())->where('key', $key)
->update(['status' => 'succeeded', 'response_code' => 201, 'response_body' => ['charge' => $charge->id]]);
});
return response()->json(['charge' => $charge->id], 201);
}
}
Згадайте унікальний індекс у БД як останню лінію захисту: перевірка в коді гонку не закриває. І назвіть, як обробляєте повтор під час processing, а не лише після завершення.