UNPKG

@shopify/cli-kit

Version:

A set of utilities, interfaces, and models that are common across all the platform features

246 lines • 8.38 kB
import { tryParseInt } from '../../common/string.js'; const MAX_NUMBER_OF_PARALLEL_REQUESTS = 5; const MARGIN_TO_RATE_LIMIT = 5; const DELAY_FOR_TOO_MANY_PARALLEL_REQUESTS = 1000; const DELAY_FOR_TOO_CLOSE_TO_API_LIMIT = 4000; const THEME_CONTEXT = 'theme'; /** * Throttles a provided action, limiting the number of globally parallel requests, or by the last seen API limit * headers. * * @param request - A function performing a request. * @returns - The result of the request, once it eventually runs. */ export async function throttle(request) { return new Promise((resolve, _reject) => { const performRequest = () => { throttlingState(THEME_CONTEXT).requestCounter += 1; resolve(request()); }; /** * Performs the request taking into account the * limit of parallel requests only when the API limit has not * been reached. * * Otherwise, performs the request to get the updated API limit * headers, so throttler parameters get updates. */ const throttleByHeader = () => { if (isReachingApiLimit()) { setTimeout(() => { throttleByParallelCounter(performRequest); }, DELAY_FOR_TOO_CLOSE_TO_API_LIMIT); } else { throttleByParallelCounter(performRequest); } }; /** * Performs the command only when the the limit * of parallel request has not been reached. * * Otherwise, defers the execution to the throttle by rate-limit function, * still respecting the limit of parallel requests. * * @param command - The action to execute. */ const throttleByParallelCounter = (command) => { if (hasTooManyRequests()) { setTimeout(() => { throttleByParallelCounter(throttleByHeader); }, DELAY_FOR_TOO_MANY_PARALLEL_REQUESTS); } else { command(); } }; /** * Start throttling by counter to get the API limit headers. */ throttleByParallelCounter(throttleByHeader); }).finally(() => { throttlingState(THEME_CONTEXT).requestCounter -= 1; }); } /** * Keep track of the latest API call limit data from a response. * * @param response - The response object. */ export function updateApiCallLimitFromResponse(response) { const callLimit = extractApiCallLimitFromResponse(response); if (!callLimit) { return; } const [used, limit] = callLimit; latestRequestInfo().apiCallLimit = { used, limit }; } function hasTooManyRequests() { return throttlingState(THEME_CONTEXT).requestCounter > MAX_NUMBER_OF_PARALLEL_REQUESTS; } function isReachingApiLimit() { const { used, limit } = latestRequestInfo().apiCallLimit; return used >= limit - MARGIN_TO_RATE_LIMIT; } function latestRequestInfo() { return throttlingState(THEME_CONTEXT).latestRequestInfo; } /** * Even considering the Stateless modules convention, * tracking information about the latest request is * critical to optimize the request throttler efficiently. * * Thus, in this case, this module deliberately avoids * IO cost and uses the `_throttlingState` instance for * that purpose. * * A context option is used if multiple APIs are using these capabilities. * * @param context - The context which we're tracking throttle state within. */ function throttlingState(context) { const stateForContext = _throttlingState[context]; if (stateForContext === undefined) { const startingState = { requestCounter: 0, latestRequestInfo: { apiCallLimit: { used: 0, limit: 40 }, }, }; _throttlingState[context] = startingState; return startingState; } else { return stateForContext; } } const _throttlingState = {}; function extractRetryDelayMsFromResponse(response) { const retryAfterStr = header(response, 'retry-after'); const retryAfter = tryParseInt(retryAfterStr); if (!retryAfter) { return 0; } return retryAfter; } /** * Retries an operation after a delay specified in the response headers. * * @param response - The response object. * @param operation - The operation to retry. * @returns - The response of the operation. */ export async function delayAwareRetry(response, operation) { const retryDelay = extractRetryDelayMsFromResponse(response); return new Promise((resolve, _reject) => { setTimeout(() => { resolve(operation()); }, retryDelay); }); } function extractApiCallLimitFromResponse(response) { const apiCallLimit = header(response, 'x-shopify-shop-api-call-limit'); const [used, limit] = apiCallLimit .split('/') .map((num) => tryParseInt(num)) .filter(Boolean); if (!used || !limit) { return; } return [used, limit]; } function header(response, name) { const headers = response.headers; const header = headers[name]; if (header?.length === 1) { return header[0] ?? ''; } return ''; } if (import.meta.vitest) { const { describe, test, expect, beforeEach } = import.meta.vitest; let response; beforeEach(() => { response = { json: {}, status: 200, headers: {}, }; }); describe('retryAfter', () => { test('when the "retry-after" header value is valid', async () => { // Given response.headers = { 'retry-after': ['2.0'], }; // When const retryAfterDelay = extractRetryDelayMsFromResponse(response); // Then expect(retryAfterDelay).toBe(2); }); test('when the "retry-after" header value is not present', async () => { // Given response.headers = { 'retry-after': [], }; // When const retryAfterDelay = extractRetryDelayMsFromResponse(response); // Then expect(retryAfterDelay).toBe(0); }); test('when the "retry-after" header value is valid', async () => { // Given response.headers = { 'retry-after': ['invalid'], }; // When const retryAfterDelay = extractRetryDelayMsFromResponse(response); // Then expect(retryAfterDelay).toBe(0); }); test('when the "retry-after" header is not present', async () => { // Given response.headers = {}; // When const retryAfterDelay = extractRetryDelayMsFromResponse(response); // Then expect(retryAfterDelay).toBe(0); }); }); describe('apiCallLimit', () => { test('when the "x-shopify-shop-api-call-limit" header is valid', async () => { // Given response.headers = { 'x-shopify-shop-api-call-limit': ['10/40'], }; // When const callLimit = extractApiCallLimitFromResponse(response); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const [used, limit] = callLimit; // Then expect(used).toBe(10); expect(limit).toBe(40); }); test('when the "x-shopify-shop-api-call-limit" header is invalid', async () => { // Given response.headers = { 'x-shopify-shop-api-call-limit': ['foo/bar'], }; // When const callLimit = extractApiCallLimitFromResponse(response); // Then expect(callLimit).toBeUndefined(); }); test('when the "x-shopify-shop-api-call-limit" header is not formatted as expected', async () => { // Given response.headers = { 'x-shopify-shop-api-call-limit': ['/10'], }; // When const callLimit = extractApiCallLimitFromResponse(response); // Then expect(callLimit).toBeUndefined(); }); }); } //# sourceMappingURL=rest-api-throttler.js.map