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, очередь), а не на этапе ожидания.

Частые вопросы

Почему axios возвращает 429 Too Many Requests?

Потому что превышен лимит частоты запросов на стороне API. Типовые значения: Ozon Seller — 1 запрос в секунду, Wildberries — 10 в секунду, Яндекс.Маркет — 100 в минуту, GitHub без токена — 60 в час, с токеном — 5000 в час.

Как добавить паузу между запросами axios без сторонних пакетов?

Через request-interceptor: он запоминает время последнего запроса и, если с него прошло меньше нужного интервала, ждёт разницу перед отправкой. Вся логика умещается примерно в двадцать строк, поэтому тащить в долгоживущий сервис отдельный пакет вроде axios-delay смысла нет — он маленький и обновляется редко.

Почему задержка не работает при Promise.all?

Это гонка на разделяемой переменной. Promise.all не выполняет запросы по очереди — он запускает всё разом, поэтому оба interceptor читают время последнего запроса до того, как хоть один его обновит, считают, что ждать не нужно, и уходят на сервер одновременно. Чтобы интервал соблюдался, слот нужно резервировать до await, а не после него, то есть строить очередь.

Как ограничить частоту запросов при параллельном коде?

Очередью, которая выдаёт слоты в одном месте и строго по одному, независимо от того, откуда пришёл вызов. Флаг processing не даёт запустить вторую обработку очереди, поэтому одновременно в полёте всегда ровно один запрос. Если лимит высокий и нужна скорость, вместо строгой очереди подойдёт p-limit с ограничением на число параллельных вызовов.

Как правильно обрабатывать ответ 429?

Уважать заголовок Retry-After: свой интервал — это гипотеза о лимите, а Retry-After — точный ответ сервера. Повтор делать через тот же экземпляр клиента, чтобы запрос снова прошёл по цепочке interceptor и попал под ограничитель — ретрай в обход ограничителя даёт второй 429 сразу после первого. Обязательно добавьте счётчик попыток и потолок ожидания, иначе при исчерпанной дневной квоте клиент уйдёт в бесконечный цикл «подождал — повторил».

Какую задержку ставить, если лимит API известен?

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