Планировщик запросов для API МойСклад
Позволяет выполнять асинхронные запросы к МойСклад без опасения получить ошибку превышения лимитов - 429 Too Many Requests
.
Чтобы уложиться в доступные лимиты полученные из заголовков ответа сервера (напр. X-RateLimit-Limit
и X-RateLimit-Remaining
), планировщик автоматически вносит задержку между выполнением отдельных запросов, а так же регулирует максимальное кол-во параллельных запросов. Прозрачно выполняет повтор запроса если ошибки избежать не удалось.
Такой подход позволяет обеспечить быстрое выполнение запросов с минимальной задержкой в моменте и стабильную работу при длительной высокой нагрузке, в том числе, при наличии задач в других процессах, разделяющих с вашим приложением общий лимит.
- Быстрый старт
- Принцип работы
- Конфигурация
- Использование
- Методы FetchPlanner
- События
- Пример: Разделение лимита между разными процессами
import { wrapFetch } from 'moysklad-fetch-planner'
const wrappedFetch = wrapFetch(fetch)
const result = await wrappedFetch(
'https://api.moysklad.ru/api/remap/1.2/entity/customerorder?limit=100',
{
headers: {
'Authorization': '...'
'Accept-Encoding': 'gzip'
}
}
)
console.log(`Номер первого заказа - ${result.rows[0].name}`)
С использованием библиотеки moysklad
import Moysklad from 'moysklad'
import { wrapFetch } from 'moysklad-fetch-planner'
// Логин и пароль указаны в переменных окружения
const ms = Moysklad({ fetch: wrapFetch(fetch) })
const result = await ms.GET('entity/customerorder', { limit: 100 })
console.log(`Номер первого заказа - ${result.rows[0].name}`)
При создании экземпляра планировщика передается объект с интерфейсом Fetch API.
Начиная с версии Node.js 18+ fetch входит в стандартную библиотеку и доступен в глобальной области видимости.
const fetchPlanner = new FetchPlanner(fetch)
Создается прокси-метод для fetch с тем же интерфейсом, но с поддержкой планирования запросов к API МойСклад.
const wrappedFetch = fetchPlanner.getFetch()
Все запросы отправленные через обернутый fetch попадают в планировщик и добавляются в очередь. При наличия свободного лимита, запрос извлекается из очереди и выполняется.
Текущий доступный лимит определяется из соответствующих HTTP заголовков в ответах сервера API МойСклад.
Планировщик автоматически рассчитывает задержку для каждого отдельного запроса которая нужна для того, чтобы не превышать указанные лимиты.
Первые запросы отправленные в планировщик, вплоть до получения первого ответа в заголовках которого содержится информация о лимитах, выполняются без задержки.
Задержка динамически меняется в зависимости от размера доступного лимита. Если лимит близок к 100%, то задержка минимальна и начинает расти с уменьшением лимита, достигая максимального значения при 0% от лимита.
В случае, если ошибка 429 Too Many Requests
всё же произошла, планировщик возвращает запрос обратно в начало очереди и повторяет его в штатном режиме согласно описанной логике.
Помимо лимита на кол-во запросов за единицу времени (RateLimit), МойСклад имеет лимит на кол-во параллельных запросов. Текущее кол-во выполняемых параллельных запросов не возвращается в ответе сервера API МойСклад. Поэтому планировщик может сделать вывод о превышении этого лимита только по факту получения ошибки 429 Too Many Requests
со значением 429005
в заголовке X-Lognex-Auth
.
При каждой такой ошибке, максимальное допустимое кол-во параллельных запросов снижается на единицу вплоть до 1
.
Через определенное время происходит пересмотр значения максимального значения параллельных запросов. И если за это время ошибка не повторялась, то значение снова увеличивается на 1
, постепенно восстанавливаясь до максимума.
Для тонкой настройки планировщика можно передавать опции:
const wrappedFetch = wrapFetch(fetch, options)
или
const fetchPlanner = new FetchPlanner(fetch, options)
Параметры options
(все опциональны):
Должен соответствовать следующему минимальному интерфейсу:
interface EventHandler {
emit(eventName: string, data?: any): void
}
Можно использовать для логирования, сбора статистики и анализа нагрузки на API.
По умолчанию: 5
(допускается значение от 1 до 20)
Коэффициент регулирующий интенсивность тротлинга запросов в зависимости от доступных в текущий момент лимитов.
Процент оставшегося лимита запросов можно рассчитать как отношение значений в заголовках ответа API
X-RateLimit-Remaining
кX-RateLimit-Limit
.
При минимальном значении коэффициента throttlingCoefficient
равном 1
задержка нарастает линейно от 0
мс при 100% лимита до maxRequestDelayTimeMs
мс при 0% лимита. При значении коэффициента больше 1
, задержка начинает нарастать нелинейно, уменьшая задержку при лимитах близких к 100% и увеличивая при уменьшении лимита. Например если коэффициент равен 10
, то тротлинга практически не будет вплоть до ~30% от лимита, а далее начнется резкое увеличение задержки до maxRequestDelayTimeMs
.
На иллюстрации пример зависимости размера задержки от лимита при различных коэффициентах:
-
Если известно, что приложение будет работать в окружении других параллельных задач, разделяющих между собой лимит запросов, то имеет смысл установить меньший коэффициент в диапазоне от 1 до 4.
-
Если приложение самостоятельно разделяет доступные лимиты и важна максимальная производительность для серий последовательных запросов, когда задержка минимальна в первые секунды и начинает нарастать уже при сохранении нагрузки, то можно увеличивать коэффициент.
Важно понимать, что, вне зависимости от размера коэффициента, при постоянной интенсивной нагрузке на API задержка всегда выходит на одно и то же среднее значение примерно равное отношению X-Lognex-Retry-TimeInterval
/ X-RateLimit-Limit
(~66 мс).
По умолчанию: 4
(максимальное значение для МойСклад 5
)
API МойСклад позволяет до 5 параллельных запросов, но для большей надежности лучше оставлять небольшой запас. Планировщик умеет определять ситуации, когда приложение сталкивается с лимитом на кол-во параллельных запросов, постепенно уменьшая указанный лимит. Но если на одном аккаунте работает много приложений или приложение интенсивно использует API, то лучше сразу явно ограничить кол-во параллельных запросов до 2-3 или даже меньше.
По умолчанию: 3000
(3 секунды)
Максимальное время задержки перед выполнением следующего запроса.
Устанавливается в случае когда оставшийся лимит равен нулю (либо произошла ошибка превышения лимита на кол-во запросов в единицу времени).
Такая ситуация может произойти если:
- работает слишком много параллельных приложений
- какое-либо параллельное приложение не контролирует лимиты
Задержка между запросами всегда лежит в диапазоне от 0 до maxRequestDelayTimeMs
.
По умолчанию 0.1
(максимальное значение 0.3
)
Доля от значения maxRequestDelayTimeMs
в пределах которой будет случайным образом изменено значение maxRequestDelayTimeMs
при расчете величины задержки между запросами.
Указание jitter'a (дрожания) способствует более равномерному распределению запросов при одновременной параллельной работе нескольких приложений.
Так же jitter применяется для расчета времени коррекции кол-ва параллельных запросов.
По умолчанию: 10000
(10 секунд)
Период через которые происходим пересмотр допустимого кол-ва параллельных запросов.
Лимит уменьшается при каждой ошибке превышения кол-ва параллельных запросов с шагом 1
Лимит постепенно увеличивается снова до maxParallelLimit
с шагом 1
через указанный промежуток времени.
Каждый отдельный экземпляр планировщика имеет независимую очередь запросов.
import { FetchPlanner } from 'moysklad-fetch-planner'
const fetchPlanner = new FetchPlanner(fetch, {
maxRequestDelayTimeMs: 1000
})
const wrappedFetch = fetchPlanner.getFetch()
const authHeader = 'Basic ' + Buffer.from(
`${process.env.MOYSKLAD_LOGIN}:${process.env.MOYSKLAD_PASSWORD}`
).toString('base64')
const result = await wrappedFetch(
'https://api.moysklad.ru/api/remap/1.2/customerorder?limit=100',
{
headers: {
'Authorization': authHeader
'Accept-Encoding': 'gzip'
}
}
)
console.log(`Номер первого заказа - ${result.rows[0].name}`)
Сокращенный вариант - передать fetch в функцию wrapFetch, которая создаст экземпляр планировщика и вернет обернутый fetch.
Параметры:
fetch
- функцияfetch
с интерфейсом FetchAPIoptions
- параметры конфигурации
import { wrapFetch } from 'moysklad-fetch-planner'
const wrappedFetch = wrapFetch(fetch, {
maxRequestDelayTimeMs: 1000
})
// ... см. пример выше
Возвращает обернутый fetch для выполнения запросов через планировщик. Исходный fetch передается в параметрах конструктора при создании экземпляра FetchPlanner.
Ожидание фактического наличия свободного слота для выполнения запроса.
Параметры:
priority
- Приоритет (number
). Чем меньше число, тем раньше будет выделен слот (по умолчанию -0
)
В примере ниже в цикле выполнятся сразу 100 запросов.
-
Если поставить
await
передms.GET(...)
, то все запросы будут выполнятся последовательно, что не подходит для ситуации когда задача выполнить запросы параллельно. -
Если убрать строку
await fetchPlanner.waitForFreeRequestSlot()
, то все 100 запросов сразу попадут в очередь планировщика, что не будет хорошим решением при большом кол-ве запросов. -
В данном случае с использованием
await fetchPlanner.waitForFreeRequestSlot()
запросы будут выполнятся параллельно, в то же время, не будут забивать очередь планировщика. И мы можем контролировать поток выполнения внутри цикла.
const resultsPromises = []
for (let i = 1; i < 100; i++) {
await fetchPlanner.waitForFreeRequestSlot()
const resultsPromise = ms.GET('entity/product', {
offset: Math.round(Math.random() * 100) + 1,
limit: 1
})
resultsPromises.push(resultsPromise)
}
const results = await Promise.all(resultsPromises)
Возвращает параметры планировщика по умолчанию либо указанные при его создании.
Возвращает текущее актуальное значение заголовка X-RateLimit-Limit
.
Возвращает текущее актуальное значение заголовка X-RateLimit-Remaining
.
Возвращает текущую длину очереди запросов.
Возвращает количество обработчиков ожидающих свободного слота.
Возвращает текущую коррекцию кол-ва допустимых параллельных запросов.
Возвращает текущее кол-во незавершенных запросов (ожидающих ответа сервера)
Возвращает задержку последнего запроса (ms)
Возвращает время мс (Unix Timestamp) на которое запланирован следующий запрос или 0
, если нет запланированных запросов.
Пример подписки на события планировщика:
import { EventEmitter } from 'node:events'
import { FetchPlanner, FetchPlannerEventMap } from 'moysklad-fetch-planner'
const eventHandler = new EventEmitter<FetchPlannerEventMap>()
eventHandler.on('request', function (ev, planner) {
console.log(ev.actionId, planner.getRateLimit())
})
eventHandler.on('response', (ev, planner) => {
console.log(ev.actionId, planner.getRateLimit())
})
const fetchPlanner = new FetchPlanner(fetch, {
eventHandler
})
Содержит поля:
Наименование | Тип | Описание |
---|---|---|
actionId |
number |
Id задачи в планировщике. |
url |
string или URL или Request |
Url запроса. |
requestId |
number |
Id отдельного запроса. Одна задача может выполнятся несколько раз при повторе в случае ошибки. |
startTime |
number |
Время начала запроса |
Содержит все поля из события request
и дополнительно:
Наименование | Тип | Описание |
---|---|---|
responseType |
string |
Тип запроса. OK - успешный запрос; RATE_LIMIT_OVERFLOW - ошибка 429 TooManyRequests; PARALLEL_LIMIT_OVERFLOW - ошибка превышения лимита параллельных запросов |
endTime |
number |
Время получения ответа |
Алгоритм планировщика позволяет динамически управлять частотой запросов в зависимости от значения текущих лимитов API МойСклад. Поэтому даже при одновременной работе нескольких приложений в разных процессах нагрузка не будет превышать в среднем 15 запросов в секунду (в соответствии с лимитами API МойСклад - не более 45 запросов в течение 3-х секунд).
На графике, по собранной статистике из реального теста, видно как пять интенсивно выполняющих запросы приложения из разных процессов автоматически делят между собой доступный лимит.
По оси X время (кол-во секунд с момента запуска первого процесса), по оси Y кол-во запросов в секунду. Общая нагрузка, независимо от кол-ва работающих приложений, в любой момент времени соответствует ~15 запросам в секунду.
Отдельные пики при старте очередного приложения связаны с тем, что в момент запуска планировщик "не знает" о текущих лимитах и первые запросы выполняются без задержки.
Конечно, для достижения такого эффекта нужно чтобы каждое приложение работало через планировщик запросов.