<!-- Canonical: https://docs.shopstory.live/docs/operations/rate-limits -->

# Ограничение запросов и повторы

ShopStory не публикует универсальные числовые лимиты для всех клиентов, API-методов и окружений. Согласуйте гарантированную квоту или SLA с ShopStory и зафиксируйте параметры в интеграционном контракте проекта.

Клиент должен обрабатывать HTTP `429`, временные `5xx`, timeout и сетевые ошибки.

## Правила повторов

1. Автоматически повторяйте только безопасные идемпотентные чтения.
2. Учитывайте `Retry-After`, если сервер его прислал.
3. Без `Retry-After` используйте экспоненциальную задержку со случайным разбросом (jitter).
4. Ограничивайте число попыток и общую продолжительность запросов (`deadline`).
5. Не повторяйте ошибки конфигурации, валидации и идентификации.
6. Не повторяйте изменение корзины автоматически без idempotency-защиты на стороне ритейлера.

Для ShopStory API проверяйте и транспортный HTTP-код, и `status` внутри JSON-конверта. Бизнес-ошибка внутри HTTP `200` не является временным сетевым сбоем.

## Пример для GET

Пример рассчитан на Node.js 22+. Общий deadline охватывает запросы и задержки между ними; тот же сигнал ограничивает чтение response body. `8000` мс — выбранный тайм-аут клиента в примере, а не квота или SLA ShopStory.

request-with-retry.mjs

```javascript
function retryDelay(response, attempt) {
  const value = response?.headers.get('Retry-After');
  if (value) {
    const seconds = Number(value);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);

    const date = Date.parse(value);
    if (Number.isFinite(date)) return Math.max(0, date - Date.now());
  }

  const backoff = Math.min(8000, 500 * 2 ** attempt);
  return backoff + Math.floor(Math.random() * 300);
}

function wait(delay, signal) {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) return reject(signal.reason);

    const onAbort = () => {
      clearTimeout(timer);
      reject(signal.reason);
    };
    const timer = setTimeout(() => {
      signal?.removeEventListener('abort', onAbort);
      resolve();
    }, delay);
    signal?.addEventListener('abort', onAbort, { once: true });
  });
}

async function getWithRetry(url, { attempts = 3, deadlineMs = 8000, signal } = {}) {
  if (!Number.isInteger(attempts) || attempts < 1) throw new TypeError('Invalid attempts');
  if (!Number.isInteger(deadlineMs) || deadlineMs < 1 || deadlineMs > 2147483647) {
    throw new TypeError('Invalid deadlineMs');
  }
  const deadline = AbortSignal.timeout(deadlineMs);
  signal = signal ? AbortSignal.any([signal, deadline]) : deadline;

  for (let attempt = 0; attempt < attempts; attempt += 1) {
    try {
      const response = await fetch(url, {
        headers: { Accept: 'application/json' },
        signal,
      });

      const retryable = response.status === 429 || response.status >= 500;
      if (!retryable) return response;
      if (attempt === attempts - 1) return response;

      await response.body?.cancel();
      await wait(Math.min(retryDelay(response, attempt), deadlineMs), signal);
    } catch (error) {
      if (signal?.aborted || attempt === attempts - 1) throw error;
      await wait(Math.min(retryDelay(null, attempt), deadlineMs), signal);
    }
  }
}
```

Добавьте следующий код в тот же файл и запустите его с выданным `SHOPSTORY_APPLICATION_ID`:

Продолжение request-with-retry.mjs

```javascript
const applicationId = process.env.SHOPSTORY_APPLICATION_ID;
if (!applicationId) throw new Error('Set SHOPSTORY_APPLICATION_ID');

const url = new URL('https://app.shopstory.live/v3/streams');
url.search = new URLSearchParams({ applicationId, limit: '10', offset: '0' });
const response = await getWithRetry(url, { attempts: 3, deadlineMs: 8000 });
if (!response.ok) throw new Error('ShopStory HTTP ' + response.status);

const payload = await response.json();
if (payload.status !== 200) throw new Error('ShopStory envelope ' + payload.status);
if (!Array.isArray(payload.body?.availableStreams)) throw new Error('Invalid streams response');

console.log(payload.body.availableStreams.length === 0
  ? 'Нет опубликованного контента: проверьте ожидаемый тестовый эфир'
  : payload.body.availableStreams.map(stream => ({ id: stream.id, status: stream.status })));
```

Timeout и отмена прерывают запрос или ожидание; итоговую HTTP-ошибку и JSON-конверт код обрабатывает отдельно. Ошибка envelope не запускает новый retry. Подробные состояния и обязательные поля: [Формат ответов](<https://docs.shopstory.live/docs/guide/architecture>) и [Каталог эфиров](<https://docs.shopstory.live/docs/guide/catalog-streams>). Не используйте устаревший ответ как подтверждение покупки.

## Опрос API

- Не запускайте отдельный бесконечный polling для каждой карточки товара.
- Останавливайте запросы, когда экран скрыт или пользователь покинул страницу.
- Увеличивайте интервал после ошибок и восстанавливайте его постепенно.
- Объединяйте одинаковые одновременные запросы.
- Для стандартного плеера используйте SDK: он сам управляет сетевыми запросами.
- Частоту запросов кастомного live UI выбирайте с учётом допустимой задержки и квоты проекта.

Каталог и состояние плеера можно кратковременно кэшировать по правилам раздела [Кэширование](<https://docs.shopstory.live/docs/operations/caching>). При покупке merchant-система проверяет актуальный остаток заново.

## Метрики

Отслеживайте:

- долю `429`, `5xx` и timeout;
- число попыток на исходный запрос;
- время от первого запроса до завершения всех попыток;
- возраст данных, показанных пользователю;
- число переходов интерфейса в деградированный режим.

Не записывайте в метрики полный URL, cookies, токены и другие учётные данные.
