Retry/backoff паттерны для B2B-интеграций 2026: Polly, axios-retry, tenacity
Retry — это не «обернул в try/catch и в while-loop». Правильная стратегия зависит от типа ошибки, expected SLA upstream-сервиса, и текущего load на recovery. Эта статья — практический справочник retry-паттернов для B2B-интеграций с примерами production-кода.
Ключевая особенность для FoxReload: API не поддерживает idempotency-ключи и вебхуки. Это означает, что POST /api/orders требует специальной логики при retry — без проверки существующего заказа повтор создаст дубль.
1. Базовая формула: exponential + jitter
function delay(attempt: number, baseMs = 500, capMs = 30000): number {
const exp = Math.min(baseMs * Math.pow(2, attempt), capMs);
const jitter = Math.random() * exp * 0.5; // ±50%
return exp / 2 + jitter; // «full jitter» AWS-style
}
// attempt 0: 250–750ms
// attempt 1: 500–1500ms
// attempt 2: 1000–3000ms
// attempt 5: 8000–24000ms
«Full jitter» (формула выше) — AWS-recommended; снижает peak load на recovery лучше, чем «equal jitter».
2. Когда ретраить и когда нет
| HTTP status | Retryable? | Comment |
|---|---|---|
| 5xx | Yes | Server-side transient |
| 429 | Yes | Respect Retry-After header |
| 408 | Yes | Client timeout |
| 4xx (rest) | No | Permanent client error |
| Network error | Yes | DNS, conn reset, TLS |
| Timeout на GET | Yes | Safe — GET идемпотентен |
| Timeout на POST /api/orders | Осторожно | Сначала проверьте GET /api/orders/ |
Особый случай для POST /api/orders: при таймауте заказ мог создаться. Перед повторным POST обязательно проверьте GET /api/orders/?statuses=active,paid,processing и ищите заказ с совпадающим itemId за последние 2 минуты. FoxReload не поддерживает idempotency-ключи, поэтому дедупликация полностью на вашей стороне.
3. axios-retry (Node.js)
import axios from 'axios';
import axiosRetry from 'axios-retry';
const client = axios.create({
baseURL: 'https://public-api.foxreload.com',
timeout: 30000,
headers: { 'X-API-Key': process.env.FOXRELOAD_KEY },
});
// Для GET-запросов — стандартный retry
axiosRetry(client, {
retries: 5,
retryDelay: (count) => axiosRetry.exponentialDelay(count) + Math.random() * 1000,
retryCondition: (err) => {
if (!err.response) return true; // network
const s = err.response.status;
return s >= 500 || s === 429 || s === 408;
},
shouldResetTimeout: true,
});
// Для POST /api/orders — нужна дополнительная проверка
async function createOrderWithDedup(items: OrderItem[]): Promise<Order> {
try {
const resp = await client.post('/api/orders', { items });
return resp.data;
} catch (err: any) {
if (isNetworkOrTimeout(err)) {
// Проверяем наличие заказа перед повтором
const existing = await findRecentOrder(items);
if (existing) return existing;
// Безопасно создать новый
const resp = await client.post('/api/orders', { items });
return resp.data;
}
throw err;
}
}
async function findRecentOrder(items: OrderItem[]): Promise<Order | null> {
const resp = await client.get('/api/orders/', {
params: { statuses: 'active,paid,processing', limit: 20 },
});
const twoMinAgo = new Date(Date.now() - 120_000).toISOString();
return resp.data.find((o: Order) =>
o.createdAt > twoMinAgo &&
items.every(item => o.items.some(oi =>
oi.product.id === item.itemId && oi.quantity === item.quantity
))
) ?? null;
}
shouldResetTimeout: true — обязательно: иначе одна 30s-timeout съест общий retry budget.
4. tenacity (Python)
from tenacity import retry, stop_after_attempt, wait_exponential_jitter, retry_if_exception_type
import httpx
API_KEY = os.environ['FOXRELOAD_KEY']
BASE = 'https://public-api.foxreload.com'
@retry(
stop=stop_after_attempt(6),
wait=wait_exponential_jitter(initial=0.5, max=30, jitter=2),
retry=retry_if_exception_type((httpx.TransportError, httpx.HTTPStatusError)),
reraise=True,
)
def get_order_status(order_id: str) -> dict:
r = httpx.get(f'{BASE}/api/orders/{order_id}',
headers={'X-API-Key': API_KEY},
timeout=30)
if r.status_code >= 500 or r.status_code == 429:
r.raise_for_status()
return r.json()
# POST /api/orders: проверяем дубли отдельно
def create_order_safe(items: list) -> dict:
try:
r = httpx.post(f'{BASE}/api/orders',
json={'items': items},
headers={'X-API-Key': API_KEY, 'Content-Type': 'application/json'},
timeout=30)
r.raise_for_status()
return r.json()
except (httpx.TransportError, httpx.TimeoutException):
# Ищем существующий заказ перед повтором
existing = find_recent_order(items)
if existing:
return existing
# Повторный POST безопасен
r = httpx.post(f'{BASE}/api/orders',
json={'items': items},
headers={'X-API-Key': API_KEY},
timeout=30)
r.raise_for_status()
return r.json()
5. Polly (C#/.NET)
var policy = Policy
.HandleResult<HttpResponseMessage>(r => (int)r.StatusCode >= 500 || (int)r.StatusCode == 429)
.Or<HttpRequestException>()
.WaitAndRetryAsync(5, attempt =>
TimeSpan.FromMilliseconds(500 * Math.Pow(2, attempt)
+ Random.Shared.Next(0, 500)));
var circuit = Policy
.HandleResult<HttpResponseMessage>(r => (int)r.StatusCode >= 500)
.CircuitBreakerAsync(5, TimeSpan.FromSeconds(60));
var combined = Policy.WrapAsync(circuit, policy);
// GET-запросы: безопасный retry
var resp = await combined.ExecuteAsync(() =>
client.GetAsync("/api/orders/{orderId}"));
// POST /api/orders: не использовать combined напрямую — нужна проверка дублей
6. Circuit breaker — когда retry уже не помогает
Если upstream фейлит 50%+ за 30 секунд — открывайте breaker и фейлите сразу:
import CircuitBreaker from 'opossum';
const breaker = new CircuitBreaker(callFoxreload, {
errorThresholdPercentage: 50,
resetTimeout: 60_000,
rollingCountTimeout: 30_000,
});
breaker.fallback(() => ({ fromCache: true }));
Это спасает downstream от лавины retry-traffic во время outage.
CTA
FoxReload API возвращает Retry-After на 429. Полные retry-recommendations и примеры интеграции — после получения доступа.
