Опрос заказов и обработка сбоев 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.
