Надёжное создание заказов без idempotency keys: паттерны для FoxReload API
Idempotency key — отличная фича в API (Stripe, например, её поддерживает). Важно знать сразу: в FoxReload API заголовок Idempotency-Key не поддерживается. Нет серверного dedup-окна, нет 24-часового хранения ключей, нет HTTP 422 idempotency_conflict. Отправка этого заголовка не даст никакого эффекта.
Это означает, что ответственность за предотвращение дублей лежит полностью на вашей стороне. Эта статья — production-паттерны клиентской дедупликации для безопасной работы с POST /api/orders.
1. Почему один retry создаёт два заказа
Простая последовательность: ваш ERP делает POST /api/orders с itemId и quantity. FoxReload создаёт заказ, начинает fulfilment, но TCP-ответ теряется (network blip). Ваш HTTP-клиент через 30s делает retry — FoxReload видит новый запрос, создаёт второй заказ, списывает второй раз с баланса. Результат: дубль.
Без idempotency-ключей на сервере этот сценарий возможен, если не применять клиентский паттерн.
2. Правильный паттерн: проверка перед созданием
import { randomUUID } from 'crypto';
interface LocalOrderRecord {
id: string; // ваш внутренний UUID
itemId: string;
quantity: number;
status: 'pending' | 'submitted' | 'confirmed';
foxreloadOrderId?: string;
}
async function createOrderSafe(itemId: string, quantity: number): Promise<string> {
// Шаг 1: создай локальную запись ДО вызова API
const localId = randomUUID();
await db.localOrders.insert({
id: localId,
itemId,
quantity,
status: 'pending',
});
try {
// Шаг 2: вызов API
const resp = await fetch('https://public-api.foxreload.com/api/orders', {
method: 'POST',
headers: {
'X-API-Key': process.env.FOXRELOAD_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({ items: [{ itemId, quantity }] }),
signal: AbortSignal.timeout(30_000),
});
const order = await resp.json();
// Шаг 3: сохрани foxreload order_id
await db.localOrders.update(localId, {
foxreloadOrderId: order.id,
status: 'submitted',
});
return order.id;
} catch (err) {
if (isNetworkError(err) || isTimeout(err)) {
// Шаг 4: при сетевой ошибке — ищи существующий заказ
return await recoverFromNetworkError(localId, itemId, quantity);
}
throw err;
}
}
3. Восстановление после network error
async function recoverFromNetworkError(
localId: string,
itemId: string,
quantity: number
): Promise<string> {
// Ждём немного — заказ может ещё появиться
await sleep(3000);
// Запрашиваем незавершённые заказы
const resp = await fetch(
'https://public-api.foxreload.com/api/orders/?statuses=active,paid,processing&limit=20',
{ headers: { 'X-API-Key': process.env.FOXRELOAD_KEY! } }
);
const orders = await resp.json();
// Ищем совпадение по itemId и quantity (по временному окну — не старше 2 минут)
const twoMinutesAgo = new Date(Date.now() - 120_000).toISOString();
const match = orders.find((o: Order) =>
o.createdAt > twoMinutesAgo &&
o.items.some((i: OrderItem) => i.product.id === itemId && i.quantity === quantity)
);
if (match) {
// Заказ уже создан — используем его
await db.localOrders.update(localId, {
foxreloadOrderId: match.id,
status: 'submitted',
});
return match.id;
}
// Заказ точно не создался — создаём новый
const newResp = await fetch('https://public-api.foxreload.com/api/orders', {
method: 'POST',
headers: {
'X-API-Key': process.env.FOXRELOAD_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({ items: [{ itemId, quantity }] }),
});
const newOrder = await newResp.json();
await db.localOrders.update(localId, {
foxreloadOrderId: newOrder.id,
status: 'submitted',
});
return newOrder.id;
}
4. Хранилища для клиентского dedup-state
| Storage | Throughput | Latency | Стоимость |
|---|---|---|---|
| Redis (hot) | 200k/s | <1ms | $0.40/M |
| Postgres UNIQUE | 30k/s | 5–8ms | $0.10/M |
| DynamoDB | 100k/s | 8–12ms | $1.25/M |
Для большинства FoxReload-интеграций достаточно Postgres UNIQUE constraint на (local_order_id) с дополнительным индексом на (foxreload_order_id). Redis нужен только при высокой нагрузке (тысячи заказов в секунду).
5. Схема таблицы для аудит-лога
CREATE TABLE order_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
item_id TEXT NOT NULL,
quantity INT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
foxreload_order_id UUID UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
error TEXT
);
CREATE INDEX idx_order_attempts_status ON order_attempts(status, created_at DESC);
Эта таблица — ваш единственный источник истины для восстановления при сбоях. Retain минимум 30 дней для сверки.
CTA
POST /api/orders создаёт заказ, GET /api/orders/{id} опрашивает статус, GET /api/orders/ ищет заказы по статусу. Полный референс — после онбординга, запросите доступ.
