Идемпотентность
Как безопасно повторять денежные запросы после таймаута или сбоя.
Сеть рвётся. Запрос на выпуск карты мог дойти и выполниться, а ответ — потеряться. Повторить его вслепую значит выпустить вторую карту и заплатить дважды. Заголовок Idempotency-Key решает это: повтор с тем же ключом возвращает исходную операцию и ничего не делает второй раз.
Где нужен ключ
| Эндпоинт | Idempotency-Key |
|---|---|
POST /cards — выпуск | обязателен |
POST /cards/{id}/topup — пополнение | обязателен |
POST /cards/{id}/close — закрытие | обязателен |
POST /cardholders — держатель | необязателен: эндпоинт и так идемпотентен по external_id |
Без заголовка там, где он обязателен, запрос не проходит проверку схемы (422).
Правила
- Один ключ — одна операция. Генерируйте UUID v4 под каждую новую операцию и сохраняйте его у себя до отправки запроса — например, в строке вашего заказа.
- Повтор — с тем же ключом. Таймаут,
5xx, обрыв соединения: отправьте тот же запрос с тем же ключом. Подпись при этом новая (новое время), см. Защита от повтора. - Ключ живёт вечно в пределах аккаунта. Сервер находит операцию по ключу и возвращает её, даже если тело нового запроса другое. Поэтому не используйте ключ повторно для другой операции — вы получите старую.
- После
failed— новый ключ. Повтор с ключом проваленной операции вернёт ту же проваленную операцию. Чтобы попробовать снова, сгенерируйте новый ключ. needs_reviewне повторяйте вовсе. Операция могла пройти; мы разбираемся вручную и сообщим итог событием.
Пример: выпуск с повтором при таймауте
import { randomUUID } from "node:crypto";
import { pp, PPError } from "./pp.mjs";
// Ключ создаётся и сохраняется ДО первой попытки.
order.idempotencyKey ??= randomUUID();
await db.save(order);
for (let attempt = 1; ; attempt++) {
try {
const op = await pp("POST", "/cards", {
tariff_id: order.tariffId,
holder_external_id: order.customerId,
amount: order.amount,
}, { idempotencyKey: order.idempotencyKey });
order.operationId = op.id; // тот же id при любом числе повторов
break;
} catch (e) {
const transient = !(e instanceof PPError) || e.status >= 500;
if (!transient || attempt === 5) throw e;
await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
}
}import time
import uuid
import requests
from pp import PPError, pp
# Ключ создаётся и сохраняется ДО первой попытки.
if not order.idempotency_key:
order.idempotency_key = str(uuid.uuid4())
order.save()
for attempt in range(1, 6):
try:
op = pp("POST", "/cards", body={
"tariff_id": order.tariff_id,
"holder_external_id": order.customer_id,
"amount": order.amount,
}, idempotency_key=order.idempotency_key)
order.operation_id = op["id"] # тот же id при любом числе повторов
break
except (PPError, requests.RequestException) as e:
transient = not isinstance(e, PPError) or e.status >= 500
if not transient or attempt == 5:
raise
time.sleep(2 ** attempt * 0.5)require __DIR__ . '/pp.php';
// Ключ создаётся и сохраняется ДО первой попытки.
$order['idempotency_key'] ??= uuid4();
saveOrder($order);
for ($attempt = 1; ; $attempt++) {
try {
$op = pp('POST', '/cards', [
'tariff_id' => $order['tariff_id'],
'holder_external_id' => $order['customer_id'],
'amount' => $order['amount'],
], $order['idempotency_key']);
$order['operation_id'] = $op['id']; // тот же id при любом числе повторов
break;
} catch (RuntimeException $e) {
$transient = $e->getCode() === 0 || $e->getCode() >= 500;
if (!$transient || $attempt === 5) {
throw $e;
}
usleep((2 ** $attempt) * 500_000);
}
}Первый и повторный ответ совпадают — тот же id:
{
"id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
"kind": "issue",
"status": "processing",
"hold_amount": "57.0464",
"net_amount": "50",
"created_at": "2026-10-07T09:13:02Z"
}Что повторять, а что нет
| Ответ | Действие |
|---|---|
Таймаут, обрыв, 5xx (кроме 503 maintenance) | Повторить с тем же ключом |
503 maintenance | Деньги не двигались. Повторить позже с тем же ключом |
429 rate_limited | Подождать и повторить с тем же ключом |
4xx с бизнес-кодом (balance_exhausted, card_not_active, …) | Не повторять как есть: исправить причину. Резерв не создавался |
Операция failed | Повторить с новым ключом, если причина устранена |
Операция needs_review | Не повторять. Ждать события |