Первый раз я столкнулся с этим на выгрузке остатков в Ozon: скрипт прекрасно работал на тестовых десяти товарах и намертво падал на боевых четырёх тысячах. В логах — стена 429 Too Many Requests, в товарах — половина не обновилась.
Проблема выглядит тривиальной («поставь sleep») ровно до момента, когда запросы начинают идти параллельно. Ниже — три способа, от однострочника до очереди, и главное — где каждый из них ломается.
Проблема
Многие API ограничивают частоту запросов (rate limiting):
- Ozon API: 1 запрос в секунду
- Wildberries API: 10 запросов в секунду
- GitHub API: 60 запросов в час (без авторизации)
При превышении лимита API возвращает ошибку 429 Too Many Requests.
Способ 1: axios-delay (npm-пакет)
Самый простой способ — готовый пакет. Годится для разовых скриптов и прототипов; в долгоживущий сервис я бы его не тащил — пакет маленький, обновляется редко, а вся его логика умещается в двадцать строк, которые вы увидите в способе 2.
npm install axios-delayimport axios from 'axios';
import delayAdapterEnhancer from 'axios-delay';
const api = axios.create({
adapter: delayAdapterEnhancer(axios.defaults.adapter),
baseURL: 'https://api-seller.ozon.ru/',
delay: 1000, // 1 секунда между запросами
headers: {
'Client-Id': process.env.OZON_CLIENT_ID,
'Api-Key': process.env.OZON_API_KEY,
},
});
// Теперь все запросы будут с задержкой
const products = await api.post('/v1/product/list', { page: 1 });
const details = await api.post('/v2/product/info', { product_id: 123 });Способ 2: Interceptor с задержкой
Тот же приём без внешних зависимостей. Interceptor запоминает время последнего запроса и, если с него прошло меньше нужного интервала, досыпает разницу:
import axios from 'axios';
function createDelayedAxios(delayMs = 1000) {
const instance = axios.create();
let lastRequestTime = 0;
instance.interceptors.request.use(async (config) => {
const now = Date.now();
const timeSinceLastRequest = now - lastRequestTime;
if (timeSinceLastRequest < delayMs) {
const waitTime = delayMs - timeSinceLastRequest;
await new Promise(resolve => setTimeout(resolve, waitTime));
}
lastRequestTime = Date.now();
return config;
});
return instance;
}
// Использование
const api = createDelayedAxios(1000);
await api.get('https://api.example.com/item/1');
await api.get('https://api.example.com/item/2'); // Автоматическая пауза 1 секРаботает только для последовательных запросов. Если запустить эти же вызовы через Promise.all, оба interceptor'а прочитают lastRequestTime до того, как хоть один его обновит, посчитают, что ждать не нужно, и уйдут на сервер одновременно. Пауза есть, лимит всё равно превышен. Классическая гонка на разделяемой переменной: между await и присваиванием lastRequestTime есть окно, в которое успевает вклиниться соседний запрос.
Именно на этом ломается большинство самописных решений. Чтобы задержка работала и при параллельном коде, интервал нужно резервировать до await, а не после него — то есть строить очередь.
Способ 3: Queue с контролем частоты
Очередь снимает проблему гонки: слоты выдаются в одном месте и строго по одному, независимо от того, откуда пришли вызовы. Это тот вариант, который в итоге едет в продакшен.
class RequestQueue {
constructor(requestsPerSecond = 1) {
this.queue = [];
this.processing = false;
this.interval = 1000 / requestsPerSecond;
this.lastRequestTime = 0;
}
async add(requestFn) {
return new Promise((resolve, reject) => {
this.queue.push({ requestFn, resolve, reject });
this.process();
});
}
async process() {
if (this.processing || this.queue.length === 0) {
return;
}
this.processing = true;
while (this.queue.length > 0) {
const now = Date.now();
const timeSinceLastRequest = now - this.lastRequestTime;
if (timeSinceLastRequest < this.interval) {
await new Promise(r => setTimeout(r, this.interval - timeSinceLastRequest));
}
const { requestFn, resolve, reject } = this.queue.shift();
try {
this.lastRequestTime = Date.now();
const result = await requestFn();
resolve(result);
} catch (error) {
reject(error);
}
}
this.processing = false;
}
}
// Использование
const queue = new RequestQueue(1); // 1 запрос в секунду
const results = await Promise.all([
queue.add(() => axios.get('/api/item/1')),
queue.add(() => axios.get('/api/item/2')),
queue.add(() => axios.get('/api/item/3')),
]);
// Запросы выполнятся последовательно с интервалом 1 секНюанс реализации: в примере выше process() защищён флагом processing, поэтому параллельные вызовы add() не запускают вторую обработку очереди — задачи просто накапливаются в массиве. Это делает очередь строго последовательной: одновременно в полёте всегда ровно один запрос.
Полный пример: клиент для Ozon API
import axios from 'axios';
class OzonApiClient {
constructor(clientId, apiKey, options = {}) {
this.requestsPerSecond = options.requestsPerSecond || 1;
this.lastRequestTime = 0;
this.client = axios.create({
baseURL: 'https://api-seller.ozon.ru/',
headers: {
'Client-Id': clientId,
'Api-Key': apiKey,
'Content-Type': 'application/json',
},
timeout: options.timeout || 30000,
});
// Добавляем задержку
this.client.interceptors.request.use(async (config) => {
await this.waitForRateLimit();
return config;
});
// Обработка ошибок rate limit
this.client.interceptors.response.use(
response => response,
async (error) => {
if (error.response?.status === 429) {
const retryAfter = error.response.headers['retry-after'] || 60;
console.log(`Rate limit hit. Waiting ${retryAfter}s...`);
await new Promise(r => setTimeout(r, retryAfter * 1000));
return this.client.request(error.config);
}
throw error;
}
);
}
async waitForRateLimit() {
const now = Date.now();
const minInterval = 1000 / this.requestsPerSecond;
const elapsed = now - this.lastRequestTime;
if (elapsed < minInterval) {
await new Promise(r => setTimeout(r, minInterval - elapsed));
}
this.lastRequestTime = Date.now();
}
async getProductList(page = 1, pageSize = 100) {
const response = await this.client.post('/v1/product/list', {
filter: { visibility: 'ALL' },
page,
page_size: pageSize,
});
return response.data.result;
}
async getProductInfo(productId) {
const response = await this.client.post('/v2/product/info', {
product_id: productId,
});
return response.data.result;
}
async getAllProducts() {
const products = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const result = await this.getProductList(page, 100);
products.push(...result.items);
hasMore = result.items.length === 100;
page++;
console.log(`Loaded ${products.length} products...`);
}
return products;
}
async getProductsWithDetails() {
const list = await this.getAllProducts();
const detailed = [];
for (const item of list) {
const info = await this.getProductInfo(item.product_id);
detailed.push(info);
console.log(`Details for ${item.product_id} loaded`);
}
return detailed;
}
}
// Использование
const ozon = new OzonApiClient(
process.env.OZON_CLIENT_ID,
process.env.OZON_API_KEY,
{ requestsPerSecond: 0.8 } // Чуть меньше лимита для надёжности
);
const products = await ozon.getProductsWithDetails();
console.log(`Total: ${products.length} products`);Два решения в этом клиенте, которые появились не сразу, а после граблей.
Обработка 429 с уважением к Retry-After. Свой интервал — это гипотеза о лимите, а заголовок Retry-After — точный ответ сервера. Если API просит подождать 60 секунд, никакие «ну я подожду секундочку и повторю» не помогут: счётчик на их стороне.
Ретрай через this.client.request(error.config), а не через рекурсию своего метода. Так повтор проходит по той же цепочке interceptor'ов и снова попадает под rate limit. Ретрай в обход ограничителя — верный способ получить второй 429 сразу после первого.
Единственный на весь код ретрай. В примере повтор не ограничен по количеству попыток: если API отдаёт 429 стабильно (например, ключ исчерпал дневную квоту), клиент уйдёт в бесконечный цикл «подождал — повторил». В боевом коде добавьте счётчик попыток и потолок ожидания.
Параллельные запросы с лимитом
Для ускорения при высоком лимите (10 RPS и выше):
import pLimit from 'p-limit';
const limit = pLimit(5); // Максимум 5 параллельных запросов
const productIds = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const results = await Promise.all(
productIds.map(id =>
limit(() => axios.get(`/api/products/${id}`))
)
);Таблица лимитов популярных API
| API | Лимит | Рекомендуемая задержка |
|---|---|---|
| Ozon Seller | 1 RPS | 1000-1200 ms |
| Wildberries | 10 RPS | 100-120 ms |
| Яндекс.Маркет | 100 RPM | 600 ms |
| GitHub (без токена) | 60 RPH | 60000 ms |
| GitHub (с токеном) | 5000 RPH | 720 ms |
Что выбрать
| Сценарий | Решение |
|---|---|
| Разовый скрипт, запросы строго по очереди | Interceptor (способ 2) |
| Долгоживущий сервис, есть параллельные вызовы | Очередь (способ 3) |
| Высокий лимит, нужна скорость | p-limit + обработка 429 |
Практический вывод простой: пока запросы идут в for ... await, хватит interceptor'а. Как только в коде появляется Promise.all или несколько независимых мест, дёргающих API, — нужен единый ограничитель на весь процесс, иначе лимит будет превышаться в моменты, которые крайне неудобно воспроизводить.
Совет: Ставьте задержку на 10–20% больше минимально требуемой. Лимит на стороне API считается по времени прихода запроса, а не отправки, — сетевые задержки играют против вас, и запас в 100–200 мс окупается отсутствием случайных блокировок.
И ещё раз про Promise.all: сам по себе он не «выполняет по очереди», а запускает всё разом и ждёт результата. Массив промисов уже создан к моменту вызова — ограничивать параллелизм нужно на этапе создания промисов (p-limit, очередь), а не на этапе ожидания.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.