UNPKG

@contentstack/management

Version:

The Content Management API is used to manage the content of your Contentstack account

596 lines (574 loc) 25.1 kB
import _defineProperty from "@babel/runtime/helpers/defineProperty"; import _asyncToGenerator from "@babel/runtime/helpers/asyncToGenerator"; function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; } function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; } import _regeneratorRuntime from "@babel/runtime/regenerator"; import Axios from 'axios'; import OAuthHandler from './oauthHandler'; import { validateAndSanitizeConfig } from './Util'; var defaultConfig = { maxRequests: 5, retryLimit: 5, retryDelay: 300, // Enhanced retry configuration for transient network failures retryOnError: true, retryOnNetworkFailure: true, retryOnDnsFailure: true, retryOnSocketFailure: true, retryOnHttpServerError: true, maxNetworkRetries: 3, networkRetryDelay: 100, // Base delay for network retries (ms) networkBackoffStrategy: 'exponential', // 'exponential' or 'fixed' delayMs: null // Delay in milliseconds before making each request }; /** * Creates a concurrency queue manager for Axios requests with retry logic and rate limiting. * @param {Object} options - Configuration options. * @param {Object} options.axios - Axios instance to manage. * @param {Object=} options.config - Queue configuration options. * @param {number=} options.config.maxRequests - Maximum concurrent requests, defaults to 5. * @param {number=} options.config.retryLimit - Maximum retry attempts for errors, defaults to 5. * @param {number=} options.config.retryDelay - Delay between retries in milliseconds, defaults to 300. * @param {boolean=} options.config.retryOnError - Enable retry on error, defaults to true. * @param {boolean=} options.config.retryOnNetworkFailure - Enable retry on network failures, defaults to true. * @param {boolean=} options.config.retryOnDnsFailure - Enable retry on DNS failures, defaults to true. * @param {boolean=} options.config.retryOnSocketFailure - Enable retry on socket failures, defaults to true. * @param {boolean=} options.config.retryOnHttpServerError - Enable retry on HTTP 5xx errors, defaults to true. * @param {number=} options.config.maxNetworkRetries - Maximum network retry attempts, defaults to 3. * @param {number=} options.config.networkRetryDelay - Base delay for network retries in milliseconds, defaults to 100. * @param {string=} options.config.networkBackoffStrategy - Backoff strategy ('exponential' or 'fixed'), defaults to 'exponential'. * @param {number=} options.config.delayMs - Delay before each request in milliseconds. * @param {Function=} options.config.retryCondition - Custom function to determine if error can be retried. * @param {Function=} options.config.logHandler - Log handler function. * @param {Function=} options.config.refreshToken - Token refresh function. * @param {string=} options.config.authtoken - Auth token. * @param {string=} options.config.authorization - Authorization token. * @returns {Object} ConcurrencyQueue instance with request/response interceptors attached to Axios. * @throws {Error} If axios instance is not provided or configuration is invalid. */ export function ConcurrencyQueue(_ref) { var _this = this; var axios = _ref.axios, config = _ref.config; if (!axios) { throw Error('Axios instance is not present'); } if (config) { if (config.maxRequests && config.maxRequests <= 0) { throw Error('Concurrency Manager Error: minimum concurrent requests is 1'); } else if (config.retryLimit && config.retryLimit <= 0) { throw Error('Retry Policy Error: minimum retry limit is 1'); } else if (config.retryDelay && config.retryDelay < 300) { throw Error('Retry Policy Error: minimum retry delay for requests is 300'); } // Validate network retry configuration if (config.maxNetworkRetries && config.maxNetworkRetries < 0) { throw Error('Network Retry Policy Error: maxNetworkRetries cannot be negative'); } if (config.networkRetryDelay && config.networkRetryDelay < 50) { throw Error('Network Retry Policy Error: minimum network retry delay is 50ms'); } } this.config = Object.assign({}, defaultConfig, config); this.queue = []; this.running = []; this.paused = false; // Helper function to determine if an error is a transient network failure var isTransientNetworkError = function isTransientNetworkError(error) { // DNS resolution failures if (_this.config.retryOnDnsFailure && error.code === 'EAI_AGAIN') { return { type: 'DNS_RESOLUTION', reason: 'DNS resolution failure (EAI_AGAIN)' }; } // Socket and connection errors if (_this.config.retryOnSocketFailure) { var socketErrorCodes = ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH']; if (socketErrorCodes.includes(error.code)) { return { type: 'SOCKET_ERROR', reason: "Socket error: ".concat(error.code) }; } } // Connection timeouts if (_this.config.retryOnNetworkFailure && error.code === 'ECONNABORTED') { return { type: 'TIMEOUT', reason: 'Connection timeout' }; } // HTTP 5xx server errors if (_this.config.retryOnHttpServerError && error.response && error.response.status >= 500 && error.response.status <= 599) { return { type: 'HTTP_SERVER_ERROR', reason: "HTTP ".concat(error.response.status, " server error") }; } return null; }; // Calculate retry delay with jitter and backoff strategy var calculateNetworkRetryDelay = function calculateNetworkRetryDelay(attempt) { var baseDelay = _this.config.networkRetryDelay; var delay; if (_this.config.networkBackoffStrategy === 'exponential') { delay = baseDelay * Math.pow(2, attempt - 1); } else { delay = baseDelay; // Fixed delay } var jitter = Math.random() * 100; return delay + jitter; }; // Log retry attempts var logRetryAttempt = function logRetryAttempt(errorInfo, attempt, delay) { var message = "Transient ".concat(errorInfo.type, " detected: ").concat(errorInfo.reason, ". Retry attempt ").concat(attempt, "/").concat(_this.config.maxNetworkRetries, " in ").concat(delay, "ms"); if (_this.config.logHandler) { _this.config.logHandler('warning', message); } else { console.warn("[Contentstack SDK] ".concat(message)); } }; // Log final failure var logFinalFailure = function logFinalFailure(errorInfo, maxRetries) { var message = "Final retry failed for ".concat(errorInfo.type, ": ").concat(errorInfo.reason, ". Exceeded max retries (").concat(maxRetries, ")."); if (_this.config.logHandler) { _this.config.logHandler('error', message); } else { console.error("[Contentstack SDK] ".concat(message)); } }; // Enhanced retry function for network errors var _retryNetworkError = function retryNetworkError(error, errorInfo, attempt) { if (attempt > _this.config.maxNetworkRetries) { logFinalFailure(errorInfo, _this.config.maxNetworkRetries); // Final error message var finalError = new Error("Network request failed after ".concat(_this.config.maxNetworkRetries, " retries: ").concat(errorInfo.reason)); finalError.code = error.code; finalError.originalError = error; finalError.retryAttempts = attempt - 1; return Promise.reject(finalError); } var delay = calculateNetworkRetryDelay(attempt); logRetryAttempt(errorInfo, attempt, delay); // Initialize retry count if not present if (!error.config.networkRetryCount) { error.config.networkRetryCount = 0; } error.config.networkRetryCount = attempt; return new Promise(function (resolve, reject) { setTimeout(function () { // Keep the request in running queue to maintain maxRequests constraint // Set retry flags to ensure proper queue handling var sanitizedConfig = validateAndSanitizeConfig(updateRequestConfig(error, "Network retry ".concat(attempt), delay)); sanitizedConfig.retryCount = sanitizedConfig.retryCount || 0; // Use axios directly but ensure the running queue is properly managed // The request interceptor will handle this retry appropriately axios(sanitizedConfig).then(function (response) { // On successful retry, call the original onComplete to properly clean up if (error.config.onComplete) { error.config.onComplete(); } shift(); // Process next queued request resolve(response); })["catch"](function (retryError) { // Check if this is still a transient error and we can retry again var retryErrorInfo = isTransientNetworkError(retryError); if (retryErrorInfo) { _retryNetworkError(retryError, retryErrorInfo, attempt + 1).then(resolve)["catch"](function (finalError) { // On final failure, clean up the running queue if (error.config.onComplete) { error.config.onComplete(); } shift(); // Process next queued request reject(finalError); }); } else { // On non-retryable error, clean up the running queue if (error.config.onComplete) { error.config.onComplete(); } shift(); // Process next queued request reject(retryError); } }); }, delay); }); }; // Initial shift will check running request, // and adds request to running queue if max requests are not running this.initialShift = /*#__PURE__*/_asyncToGenerator(/*#__PURE__*/_regeneratorRuntime.mark(function _callee() { return _regeneratorRuntime.wrap(function (_context) { while (1) switch (_context.prev = _context.next) { case 0: if (!(_this.running.length < _this.config.maxRequests && !_this.paused)) { _context.next = 1; break; } _context.next = 1; return shift(); case 1: case "end": return _context.stop(); } }, _callee); })); // INTERNAL: Shift the queued item to running queue var shift = /*#__PURE__*/function () { var _ref3 = _asyncToGenerator(/*#__PURE__*/_regeneratorRuntime.mark(function _callee2() { var queueItem; return _regeneratorRuntime.wrap(function (_context2) { while (1) switch (_context2.prev = _context2.next) { case 0: if (!(_this.queue.length && !_this.paused)) { _context2.next = 2; break; } queueItem = _this.queue.shift(); // Add configurable delay before making the request if specified if (!(_this.config.delayMs && _this.config.delayMs > 0)) { _context2.next = 1; break; } _context2.next = 1; return new Promise(function (resolve) { return setTimeout(resolve, _this.config.delayMs); }); case 1: queueItem.resolve(queueItem.request); _this.running.push(queueItem); case 2: case "end": return _context2.stop(); } }, _callee2); })); return function shift() { return _ref3.apply(this, arguments); }; }(); // Append the request at start of queue this.unshift = function (requestPromise) { _this.queue.unshift(requestPromise); }; this.push = function (requestPromise) { _this.queue.push(requestPromise); _this.initialShift()["catch"](console.error); }; this.clear = function () { var requests = _this.queue.splice(0, _this.queue.length); requests.forEach(function (element) { element.request.source.cancel(); }); }; // Detach the interceptors this.detach = function () { axios.interceptors.request.eject(_this.interceptors.request); axios.interceptors.response.eject(_this.interceptors.response); _this.interceptors = { request: null, response: null }; }; // Request interceptor to queue the request var requestHandler = function requestHandler(request) { var _axios$oauth; if (typeof request.data === 'function') { request.formdata = request.data; request.data = transformFormData(request); } if (axios !== null && axios !== void 0 && (_axios$oauth = axios.oauth) !== null && _axios$oauth !== void 0 && _axios$oauth.accessToken) { var isTokenExpired = axios.oauth.tokenExpiryTime && Date.now() > axios.oauth.tokenExpiryTime; if (isTokenExpired) { return refreshAccessToken()["catch"](function (error) { throw new Error('Failed to refresh access token: ' + error.message); }); } } request.retryCount = (request === null || request === void 0 ? void 0 : request.retryCount) || 0; setAuthorizationHeaders(request); if (request.cancelToken === undefined) { var source = Axios.CancelToken.source(); request.cancelToken = source.token; request.source = source; } if (_this.paused && request.retryCount > 0) { return new Promise(function (resolve, reject) { _this.unshift({ request: request, resolve: resolve, reject: reject }); }); } else if (request.retryCount > 0) { return request; } return new Promise(function (resolve, reject) { request.onComplete = function () { _this.running.pop({ request: request, resolve: resolve, reject: reject }); }; _this.push({ request: request, resolve: resolve, reject: reject }); }); }; var setAuthorizationHeaders = function setAuthorizationHeaders(request) { var _axios$oauth2; if (request.headers.authorization && request.headers.authorization !== undefined) { if (_this.config.authorization && _this.config.authorization !== undefined) { request.headers.authorization = _this.config.authorization; request.authorization = _this.config.authorization; } delete request.headers.authtoken; } else if (request.headers.authtoken && request.headers.authtoken !== undefined && _this.config.authtoken && _this.config.authtoken !== undefined) { request.headers.authtoken = _this.config.authtoken; request.authtoken = _this.config.authtoken; } else if (axios !== null && axios !== void 0 && (_axios$oauth2 = axios.oauth) !== null && _axios$oauth2 !== void 0 && _axios$oauth2.accessToken) { // If OAuth access token is available in axios instance request.headers.authorization = "Bearer ".concat(axios.oauth.accessToken); request.authorization = "Bearer ".concat(axios.oauth.accessToken); delete request.headers.authtoken; } }; // Refresh Access Token var refreshAccessToken = /*#__PURE__*/function () { var _ref4 = _asyncToGenerator(/*#__PURE__*/_regeneratorRuntime.mark(function _callee3() { var _t; return _regeneratorRuntime.wrap(function (_context3) { while (1) switch (_context3.prev = _context3.next) { case 0: _context3.prev = 0; _context3.next = 1; return new OAuthHandler(axios).refreshAccessToken(); case 1: _this.paused = false; // Resume the request queue once the token is refreshed // Retry the requests that were pending due to token expiration _this.running.forEach(function (_ref5) { var request = _ref5.request, resolve = _ref5.resolve, reject = _ref5.reject; // Retry the request with sanitized configuration to prevent SSRF var sanitizedConfig = validateAndSanitizeConfig(request); axios(sanitizedConfig).then(resolve)["catch"](reject); }); _this.running = []; // Clear the running queue after retrying requests _context3.next = 3; break; case 2: _context3.prev = 2; _t = _context3["catch"](0); _this.paused = false; // stop queueing requests on failure _this.running.forEach(function (_ref6) { var reject = _ref6.reject; return reject(_t); }); // Reject all queued requests _this.running = []; // Clear the running queue case 3: case "end": return _context3.stop(); } }, _callee3, null, [[0, 2]]); })); return function refreshAccessToken() { return _ref4.apply(this, arguments); }; }(); var _delay = function delay(time) { var isRefreshToken = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; if (!_this.paused) { _this.paused = true; // Check for current running request. // Wait for running queue to complete. // Wait and prosed the Queued request. if (_this.running.length > 0) { setTimeout(function () { _delay(time, isRefreshToken); }, time); } return new Promise(function (resolve) { return setTimeout(function () { _this.paused = false; if (isRefreshToken) { return refreshToken(); } else { for (var i = 0; i < _this.config.maxRequests; i++) { _this.initialShift()["catch"](console.error); } } }, time); }); } }; var refreshToken = function refreshToken() { return config.refreshToken().then(function (token) { if (token.authorization) { axios.defaults.headers.authorization = token.authorization; axios.defaults.authorization = token.authorization; axios.httpClientParams.authorization = token.authorization; axios.httpClientParams.headers.authorization = token.authorization; _this.config.authorization = token.authorization; } else if (token.authtoken) { axios.defaults.headers.authtoken = token.authtoken; axios.defaults.authtoken = token.authtoken; axios.httpClientParams.authtoken = token.authtoken; axios.httpClientParams.headers.authtoken = token.authtoken; _this.config.authtoken = token.authtoken; } })["catch"](function (error) { _this.queue.forEach(function (queueItem) { queueItem.reject({ errorCode: '401', errorMessage: error instanceof Error ? error.message : error, code: 'Unauthorized', message: 'Unable to refresh token', name: 'Token Error', config: queueItem.request, stack: error instanceof Error ? error.stack : null }); }); _this.queue = []; _this.running = []; })["finally"](function () { _this.queue.forEach(function (queueItem) { if (_this.config.authorization) { queueItem.request.headers.authorization = _this.config.authorization; queueItem.request.authorization = _this.config.authorization; } if (_this.config.authtoken) { queueItem.request.headers.authtoken = _this.config.authtoken; queueItem.request.authtoken = _this.config.authtoken; } }); for (var i = 0; i < _this.config.maxRequests; i++) { _this.initialShift()["catch"](console.error); } }); }; // Response interceptor used for var responseHandler = function responseHandler(response) { response.config.onComplete(); shift(); return response; }; var responseErrorHandler = function responseErrorHandler(error) { var networkError = error.config.retryCount; var retryErrorType = null; // First, check for transient network errors var networkErrorInfo = isTransientNetworkError(error); if (networkErrorInfo && _this.config.retryOnNetworkFailure) { var networkRetryCount = error.config.networkRetryCount || 0; return _retryNetworkError(error, networkErrorInfo, networkRetryCount + 1); } // Original retry logic for non-network errors if (!_this.config.retryOnError || networkError > _this.config.retryLimit) { return Promise.reject(responseHandler(error)); } // Check rate limit remaining header before retrying var wait = _this.config.retryDelay; var response = error.response; if (!response) { if (error.code === 'ECONNABORTED') { var timeoutMs = error.config.timeout || _this.config.timeout || 'unknown'; error.response = _objectSpread(_objectSpread({}, error.response), {}, { status: 408, statusText: "timeout of ".concat(timeoutMs, "ms exceeded") }); response = error.response; } else { return Promise.reject(responseHandler(error)); } } else if (response.status === 401 && _this.config.refreshToken) { retryErrorType = "Error with status: ".concat(response.status); networkError++; if (networkError > _this.config.retryLimit) { return Promise.reject(responseHandler(error)); } _this.running.shift(); // Cool down the running requests _delay(wait, response.status === 401); error.config.retryCount = networkError; // SSRF Prevention: Validate URL before making request var sanitizedConfig = validateAndSanitizeConfig(updateRequestConfig(error, retryErrorType, wait)); return axios(sanitizedConfig); } if (_this.config.retryCondition && _this.config.retryCondition(error)) { retryErrorType = error.response ? "Error with status: ".concat(response.status) : "Error Code:".concat(error.code); networkError++; return _this.retry(error, retryErrorType, networkError, wait); } return Promise.reject(responseHandler(error)); }; this.retry = function (error, retryErrorType, retryCount, waittime) { var delaytime = waittime; if (retryCount > _this.config.retryLimit) { return Promise.reject(responseHandler(error)); } if (_this.config.retryDelayOptions) { if (_this.config.retryDelayOptions.customBackoff) { delaytime = _this.config.retryDelayOptions.customBackoff(retryCount, error); if (delaytime && delaytime <= 0) { return Promise.reject(responseHandler(error)); } } else if (_this.config.retryDelayOptions.base) { delaytime = _this.config.retryDelayOptions.base * retryCount; } } else { delaytime = _this.config.retryDelay; } error.config.retryCount = retryCount; return new Promise(function (resolve) { return setTimeout(function () { // SSRF Prevention: Validate URL before making request var sanitizedConfig = validateAndSanitizeConfig(updateRequestConfig(error, retryErrorType, delaytime)); return resolve(axios(sanitizedConfig)); }, delaytime); }); }; this.interceptors = { request: null, response: null }; var updateRequestConfig = function updateRequestConfig(error, retryErrorType, wait) { var requestConfig = error.config; var message = "".concat(retryErrorType, " error occurred. Waiting for ").concat(wait, " ms before retrying..."); if (_this.config.logHandler) { _this.config.logHandler('warning', message); } else { console.warn("[Contentstack SDK] ".concat(message)); } if (axios !== undefined && axios.defaults !== undefined) { if (axios.defaults.agent === requestConfig.agent) { delete requestConfig.agent; } if (axios.defaults.httpAgent === requestConfig.httpAgent) { delete requestConfig.httpAgent; } if (axios.defaults.httpsAgent === requestConfig.httpsAgent) { delete requestConfig.httpsAgent; } } requestConfig.data = transformFormData(requestConfig); requestConfig.transformRequest = [function (data) { return data; }]; return requestConfig; }; var transformFormData = function transformFormData(request) { if (request.formdata) { var formdata = request.formdata(); request.headers = _objectSpread(_objectSpread({}, request.headers), formdata.getHeaders()); return formdata; } return request.data; }; // Adds interseptors in axios to queue request this.interceptors.request = axios.interceptors.request.use(requestHandler); this.interceptors.response = axios.interceptors.response.use(responseHandler, responseErrorHandler); }