Главная/Статьи/Axios: добавление задержки между запросами

Axios: добавление задержки между запросами

Решение проблемы rate limiting при работе с API. Три способа добавить паузу между HTTP-запросами в JavaScript.

ДМ
Дмитрий Мещеряков
📅 20 июля 2021 г.📖 7 мин чтения

Первый раз я столкнулся с этим на выгрузке остатков в 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.

bash
npm install axios-delay
javascript
import 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 запоминает время последнего запроса и, если с него прошло меньше нужного интервала, досыпает разницу:

javascript
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 с контролем частоты

Очередь снимает проблему гонки: слоты выдаются в одном месте и строго по одному, независимо от того, откуда пришли вызовы. Это тот вариант, который в итоге едет в продакшен.

javascript
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

javascript
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 и выше):

javascript
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 Seller1 RPS1000-1200 ms
Wildberries10 RPS100-120 ms
Яндекс.Маркет100 RPM600 ms
GitHub (без токена)60 RPH60000 ms
GitHub (с токеном)5000 RPH720 ms

Что выбрать

СценарийРешение
Разовый скрипт, запросы строго по очередиInterceptor (способ 2)
Долгоживущий сервис, есть параллельные вызовыОчередь (способ 3)
Высокий лимит, нужна скоростьp-limit + обработка 429

Практический вывод простой: пока запросы идут в for ... await, хватит interceptor'а. Как только в коде появляется Promise.all или несколько независимых мест, дёргающих API, — нужен единый ограничитель на весь процесс, иначе лимит будет превышаться в моменты, которые крайне неудобно воспроизводить.

💡 Совет

Совет: Ставьте задержку на 10–20% больше минимально требуемой. Лимит на стороне API считается по времени прихода запроса, а не отправки, — сетевые задержки играют против вас, и запас в 100–200 мс окупается отсутствием случайных блокировок.

⚠️ Важно

И ещё раз про Promise.all: сам по себе он не «выполняет по очереди», а запускает всё разом и ждёт результата. Массив промисов уже создан к моменту вызова — ограничивать параллелизм нужно на этапе создания промисов (p-limit, очередь), а не на этапе ожидания.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.