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

Опрос заказов и обработка сбоев 2026: паттерны для B2B API цифровых товаров

Боевые паттерны опроса статуса заказов при отсутствии вебхуков: poll-loop, DLQ для застрявших заказов, клиентская дедупликация и SLA-алёрты.

Опрос заказов и обработка сбоев 2026: паттерны для B2B API цифровых товаров

Ключевое, что нужно знать при интеграции FoxReload: вебхуков не существует. Нет X-FoxReload-Signature, нет HMAC-колбэков, нет событий order.*, нет настройки вебхук-эндпоинтов. Единственный способ получить статус заказа — опрос GET /api/orders/{order_id}.

Это не проблема — это паттерн. Эта статья описывает production-архитектуру poll-loop с обработкой сбоев, которая обеспечивает 99.9%+ успешную доставку кодов.

1. Poll-loop с exponential backoff — математика

Простой ретрай каждые 3 секунды создаёт лишнюю нагрузку при большом числе одновременных заказов. Правильная формула:

function pollInterval(attempt: number): number {
  const base = 2_000;   // 2s
  const cap = 30_000;   // 30s
  const exp = Math.min(base * Math.pow(1.5, attempt), cap);
  const jitter = Math.random() * exp * 0.2; // ±20%
  return exp + jitter;
}
// attempt 0: ~2s
// attempt 1: ~3s
// attempt 2: ~4.5s
// attempt 5: ~15s
// attempt 8+: ~30s

Применяйте backoff между опросами одного заказа, но не между разными заказами — фоновый воркер должен обрабатывать все pending-заказы параллельно.

2. Основной poll worker

// Express + BullMQ
import Queue from 'bullmq';

const pollQueue = new Queue('order-polling');

// При создании заказа через FoxReload — добавить в очередь опроса
async function afterOrderCreated(foxreloadOrderId: string, localOrderId: string) {
  await pollQueue.add('poll-order', {
    foxreloadOrderId,
    localOrderId,
    attempt: 0,
  }, {
    delay: 2000,  // первый опрос через 2s
  });
}

// Воркер
pollQueue.process('poll-order', async (job) => {
  const { foxreloadOrderId, localOrderId, attempt } = job.data;

  const resp = await fetch(
    `https://public-api.foxreload.com/api/orders/${foxreloadOrderId}`,
    { headers: { 'X-API-Key': process.env.FOXRELOAD_KEY! } }
  );
  const order = await resp.json();

  if (['completed', 'cancelled', 'failed'].includes(order.status)) {
    // Терминальное состояние — обрабатываем результат
    await handleTerminalStatus(localOrderId, order);
    return;
  }

  // Ещё не завершён — ставим следующий опрос
  if (attempt >= 30) {
    // Более 30 попыток — в DLQ для ручного разбора
    await dlq.add('stuck-order', { foxreloadOrderId, localOrderId });
    return;
  }

  await pollQueue.add('poll-order', {
    foxreloadOrderId,
    localOrderId,
    attempt: attempt + 1,
  }, {
    delay: pollInterval(attempt),
  });
});

3. Обработка completed и failed

async function handleTerminalStatus(localOrderId: string, remoteOrder: FoxOrder) {
  if (remoteOrder.status === 'completed') {
    // Извлекаем коды из каждой позиции
    const codes = remoteOrder.items.flatMap(item => item.externalData ?? []);

    await db.orders.update(localOrderId, {
      status: 'completed',
      codes: JSON.stringify(codes),
      completedAt: new Date(),
    });

    // Доставляем коды покупателю
    await deliverCodesToCustomer(localOrderId, codes);

  } else if (remoteOrder.status === 'cancelled') {
    await db.orders.update(localOrderId, {
      status: 'cancelled',
      cancelReason: remoteOrder.cancelReason,
    });
    await handleCancellation(localOrderId, remoteOrder.cancelReason);

  } else if (remoteOrder.status === 'failed') {
    // Ищем ошибки по конкретным позициям
    const errors = remoteOrder.items
      .filter(i => i.error)
      .map(i => ({ productId: i.product.id, error: i.error }));

    await db.orders.update(localOrderId, {
      status: 'failed',
      itemErrors: JSON.stringify(errors),
    });
    await handleFailure(localOrderId, errors);
  }
}

4. Dead-letter queue для застрявших заказов

Заказы, не достигшие терминального состояния за разумное время (5–10 минут), требуют ручного разбора:

dlq.process('stuck-order', async (job) => {
  const { foxreloadOrderId, localOrderId } = job.data;

  // Финальная проверка статуса
  const resp = await fetch(
    `https://public-api.foxreload.com/api/orders/${foxreloadOrderId}`,
    { headers: { 'X-API-Key': process.env.FOXRELOAD_KEY! } }
  );
  const order = await resp.json();

  if (['completed', 'cancelled', 'failed'].includes(order.status)) {
    // Вдруг завершился — обрабатываем нормально
    await handleTerminalStatus(localOrderId, order);
    return;
  }

  // Алёрт в PagerDuty/Telegram/Slack
  await alert.critical({
    title: 'Stuck order in FoxReload',
    foxreloadOrderId,
    localOrderId,
    currentStatus: order.status,
    createdAt: order.createdAt,
  });

  await db.orders.update(localOrderId, { status: 'requires_manual_review' });
});

5. Клиентская дедупликация при отсутствии idempotency-ключей

FoxReload не поддерживает Idempotency-Key. При network error на POST /api/orders заказ мог создаться:

async function createOrderSafe(items: OrderItem[]): Promise<FoxOrder> {
  // Сохранить pending-запись локально ДО вызова API
  const localId = await db.orders.insertPending(items);

  try {
    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 }),
      signal: AbortSignal.timeout(30_000),
    });
    const order = await resp.json();
    await db.orders.update(localId, { foxreloadId: order.id });
    return order;
  } catch (err) {
    if (isNetworkOrTimeout(err)) {
      // Ищем возможный дубль перед повтором
      await sleep(3000);
      const recent = await fetchRecentOrders();
      const match = findMatch(recent, items);
      if (match) {
        await db.orders.update(localId, { foxreloadId: match.id });
        return match;
      }
      // Нет совпадений — создаём новый
    }
    throw err;
  }
}

6. Alerting на >1% незавершённых заказов

Метрика, которую нужно мониторить: rolling 5-минутный процент заказов, не достигших терминального состояния в норматив. Prometheus-правило:

- alert: OrderFulfilmentDegraded
  expr: |
    (
      count(order_status{status=~"active|paid|processing", age_minutes > 5})
      / count(order_status{age_minutes > 5})
    ) > 0.01
  for: 2m
  labels: { severity: page }
  annotations:
    summary: ">1% заказов висят в незавершённом состоянии более 5 минут"

Алёрт идёт в PagerDuty/Opsgenie. В 80% случаев причина — проблема с балансом (BalanceNotEnough) или временная недоступность FoxReload API.

CTA

Полный гайд по созданию заказов и структуре ответа — после онбординга. Запросите доступ к API.

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

Поддерживает ли FoxReload вебхуки для статусов заказов?
Нет. FoxReload API не отправляет вебхуки. Нет X-FoxReload-Signature, нет HMAC-колбэков, нет событий order.*. Единственный способ узнать результат заказа — опросить GET /api/orders/{order_id} до достижения терминального состояния.
Как быстро заказ переходит в completed?
Зависит от типа товара. Большинство заказов достигают completed в течение нескольких секунд–минут. Если заказ висит в processing более 5–10 минут — это повод для алерта. Терминальные состояния: completed, cancelled, failed.
Как защититься от дублей при отсутствии idempotency-ключей?
Сохраняйте pending-запись локально ДО вызова POST /api/orders. При network error — опрашивайте GET /api/orders/?statuses=active,paid,processing и ищите совпадение по itemId/quantity за последние 2 минуты. Только если не найдено — создавайте новый заказ.
Какой timeout должен быть на poll-интервал?
Для свежесозданных заказов: проверяйте каждые 2–5 секунд первые 30 секунд, затем каждые 10–30 секунд. После 5 минут без изменения статуса — переводите в DLQ для ручного разбора. Используйте exponential backoff между попытками опроса.
Получить доступ к FoxReload API

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