Оптовая платформа цифровых товаров

Надёжное создание заказов без idempotency keys: паттерны для FoxReload API

FoxReload не поддерживает Idempotency-Key. Разбираем, как строить retry-safe создание заказов: проверка перед повтором, клиентский dedup, обработка timeout-ов.

Надёжное создание заказов без 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/ ищет заказы по статусу. Полный референс — после онбординга, запросите доступ.

Часто задаваемые вопросы

Есть ли в FoxReload поддержка заголовка Idempotency-Key?
Нет. FoxReload API не обрабатывает заголовок Idempotency-Key. При повторном POST /api/orders создаётся новый заказ. Правильный подход: сохранить pending-запись локально до вызова API, а при сетевой ошибке — проверить GET /api/orders/ на наличие уже созданного заказа.
Что произойдёт, если POST /api/orders завершился timeout?
Заказ мог создаться или не создаться на стороне FoxReload. Правильный следующий шаг — опросить GET /api/orders/?statuses=active,paid,processing и найти совпадение по itemId/quantity. Если найден — использовать существующий заказ. Если нет — создать новый.
Нужен ли какой-то ключ для GET-запросов?
Нет. GET идемпотентен по определению — повтор GET не создаёт side effects. Дедупликация нужна только для POST /api/orders.
Как безопасно ретраить создание заказа?
1. Сохранить pending-запись локально с itemId и суммой. 2. Вызвать POST /api/orders. 3. При успехе — сохранить foxreload_order_id. 4. При network error — опросить GET /api/orders/ и сопоставить с pending-записью. 5. Только если совпадения нет — создать новый заказ.
Получить доступ к FoxReload API

Похожие статьи