transporter.cjs.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. 'use strict';
  2. Object.defineProperty(exports, '__esModule', { value: true });
  3. var requesterCommon = require('@algolia/requester-common');
  4. function createMappedRequestOptions(requestOptions, timeout) {
  5. const options = requestOptions || {};
  6. const data = options.data || {};
  7. Object.keys(options).forEach(key => {
  8. if (['timeout', 'headers', 'queryParameters', 'data', 'cacheable'].indexOf(key) === -1) {
  9. data[key] = options[key]; // eslint-disable-line functional/immutable-data
  10. }
  11. });
  12. return {
  13. data: Object.entries(data).length > 0 ? data : undefined,
  14. timeout: options.timeout || timeout,
  15. headers: options.headers || {},
  16. queryParameters: options.queryParameters || {},
  17. cacheable: options.cacheable,
  18. };
  19. }
  20. const CallEnum = {
  21. /**
  22. * If the host is read only.
  23. */
  24. Read: 1,
  25. /**
  26. * If the host is write only.
  27. */
  28. Write: 2,
  29. /**
  30. * If the host is both read and write.
  31. */
  32. Any: 3,
  33. };
  34. const HostStatusEnum = {
  35. Up: 1,
  36. Down: 2,
  37. Timeouted: 3,
  38. };
  39. // By default, API Clients at Algolia have expiration delay
  40. // of 5 mins. In the JavaScript client, we have 2 mins.
  41. const EXPIRATION_DELAY = 2 * 60 * 1000;
  42. function createStatefulHost(host, status = HostStatusEnum.Up) {
  43. return {
  44. ...host,
  45. status,
  46. lastUpdate: Date.now(),
  47. };
  48. }
  49. function isStatefulHostUp(host) {
  50. return host.status === HostStatusEnum.Up || Date.now() - host.lastUpdate > EXPIRATION_DELAY;
  51. }
  52. function isStatefulHostTimeouted(host) {
  53. return (host.status === HostStatusEnum.Timeouted && Date.now() - host.lastUpdate <= EXPIRATION_DELAY);
  54. }
  55. function createStatelessHost(options) {
  56. if (typeof options === 'string') {
  57. return {
  58. protocol: 'https',
  59. url: options,
  60. accept: CallEnum.Any,
  61. };
  62. }
  63. return {
  64. protocol: options.protocol || 'https',
  65. url: options.url,
  66. accept: options.accept || CallEnum.Any,
  67. };
  68. }
  69. function createRetryableOptions(hostsCache, statelessHosts) {
  70. return Promise.all(statelessHosts.map(statelessHost => {
  71. return hostsCache.get(statelessHost, () => {
  72. return Promise.resolve(createStatefulHost(statelessHost));
  73. });
  74. })).then(statefulHosts => {
  75. const hostsUp = statefulHosts.filter(host => isStatefulHostUp(host));
  76. const hostsTimeouted = statefulHosts.filter(host => isStatefulHostTimeouted(host));
  77. /**
  78. * Note, we put the hosts that previously timeouted on the end of the list.
  79. */
  80. const hostsAvailable = [...hostsUp, ...hostsTimeouted];
  81. const statelessHostsAvailable = hostsAvailable.length > 0
  82. ? hostsAvailable.map(host => createStatelessHost(host))
  83. : statelessHosts;
  84. return {
  85. getTimeout(timeoutsCount, baseTimeout) {
  86. /**
  87. * Imagine that you have 4 hosts, if timeouts will increase
  88. * on the following way: 1 (timeouted) > 4 (timeouted) > 5 (200)
  89. *
  90. * Note that, the very next request, we start from the previous timeout
  91. *
  92. * 5 (timeouted) > 6 (timeouted) > 7 ...
  93. *
  94. * This strategy may need to be reviewed, but is the strategy on the our
  95. * current v3 version.
  96. */
  97. const timeoutMultiplier = hostsTimeouted.length === 0 && timeoutsCount === 0
  98. ? 1
  99. : hostsTimeouted.length + 3 + timeoutsCount;
  100. return timeoutMultiplier * baseTimeout;
  101. },
  102. statelessHosts: statelessHostsAvailable,
  103. };
  104. });
  105. }
  106. const isNetworkError = ({ isTimedOut, status }) => {
  107. return !isTimedOut && ~~status === 0;
  108. };
  109. const isRetryable = (response) => {
  110. const status = response.status;
  111. const isTimedOut = response.isTimedOut;
  112. return (isTimedOut || isNetworkError(response) || (~~(status / 100) !== 2 && ~~(status / 100) !== 4));
  113. };
  114. const isSuccess = ({ status }) => {
  115. return ~~(status / 100) === 2;
  116. };
  117. const retryDecision = (response, outcomes) => {
  118. if (isRetryable(response)) {
  119. return outcomes.onRetry(response);
  120. }
  121. if (isSuccess(response)) {
  122. return outcomes.onSuccess(response);
  123. }
  124. return outcomes.onFail(response);
  125. };
  126. function retryableRequest(transporter, statelessHosts, request, requestOptions) {
  127. const stackTrace = []; // eslint-disable-line functional/prefer-readonly-type
  128. /**
  129. * First we prepare the payload that do not depend from hosts.
  130. */
  131. const data = serializeData(request, requestOptions);
  132. const headers = serializeHeaders(transporter, requestOptions);
  133. const method = request.method;
  134. // On `GET`, the data is proxied to query parameters.
  135. const dataQueryParameters = request.method !== requesterCommon.MethodEnum.Get
  136. ? {}
  137. : {
  138. ...request.data,
  139. ...requestOptions.data,
  140. };
  141. const queryParameters = {
  142. 'x-algolia-agent': transporter.userAgent.value,
  143. ...transporter.queryParameters,
  144. ...dataQueryParameters,
  145. ...requestOptions.queryParameters,
  146. };
  147. let timeoutsCount = 0; // eslint-disable-line functional/no-let
  148. const retry = (hosts, // eslint-disable-line functional/prefer-readonly-type
  149. getTimeout) => {
  150. /**
  151. * We iterate on each host, until there is no host left.
  152. */
  153. const host = hosts.pop(); // eslint-disable-line functional/immutable-data
  154. if (host === undefined) {
  155. throw createRetryError(stackTraceWithoutCredentials(stackTrace));
  156. }
  157. const payload = {
  158. data,
  159. headers,
  160. method,
  161. url: serializeUrl(host, request.path, queryParameters),
  162. connectTimeout: getTimeout(timeoutsCount, transporter.timeouts.connect),
  163. responseTimeout: getTimeout(timeoutsCount, requestOptions.timeout),
  164. };
  165. /**
  166. * The stackFrame is pushed to the stackTrace so we
  167. * can have information about onRetry and onFailure
  168. * decisions.
  169. */
  170. const pushToStackTrace = (response) => {
  171. const stackFrame = {
  172. request: payload,
  173. response,
  174. host,
  175. triesLeft: hosts.length,
  176. };
  177. // eslint-disable-next-line functional/immutable-data
  178. stackTrace.push(stackFrame);
  179. return stackFrame;
  180. };
  181. const decisions = {
  182. onSuccess: response => deserializeSuccess(response),
  183. onRetry(response) {
  184. const stackFrame = pushToStackTrace(response);
  185. /**
  186. * If response is a timeout, we increaset the number of
  187. * timeouts so we can increase the timeout later.
  188. */
  189. if (response.isTimedOut) {
  190. timeoutsCount++;
  191. }
  192. return Promise.all([
  193. /**
  194. * Failures are individually send the logger, allowing
  195. * the end user to debug / store stack frames even
  196. * when a retry error does not happen.
  197. */
  198. transporter.logger.info('Retryable failure', stackFrameWithoutCredentials(stackFrame)),
  199. /**
  200. * We also store the state of the host in failure cases. If the host, is
  201. * down it will remain down for the next 2 minutes. In a timeout situation,
  202. * this host will be added end of the list of hosts on the next request.
  203. */
  204. transporter.hostsCache.set(host, createStatefulHost(host, response.isTimedOut ? HostStatusEnum.Timeouted : HostStatusEnum.Down)),
  205. ]).then(() => retry(hosts, getTimeout));
  206. },
  207. onFail(response) {
  208. pushToStackTrace(response);
  209. throw deserializeFailure(response, stackTraceWithoutCredentials(stackTrace));
  210. },
  211. };
  212. return transporter.requester.send(payload).then(response => {
  213. return retryDecision(response, decisions);
  214. });
  215. };
  216. /**
  217. * Finally, for each retryable host perform request until we got a non
  218. * retryable response. Some notes here:
  219. *
  220. * 1. The reverse here is applied so we can apply a `pop` later on => more performant.
  221. * 2. We also get from the retryable options a timeout multiplier that is tailored
  222. * for the current context.
  223. */
  224. return createRetryableOptions(transporter.hostsCache, statelessHosts).then(options => {
  225. return retry([...options.statelessHosts].reverse(), options.getTimeout);
  226. });
  227. }
  228. function createTransporter(options) {
  229. const { hostsCache, logger, requester, requestsCache, responsesCache, timeouts, userAgent, hosts, queryParameters, headers, } = options;
  230. const transporter = {
  231. hostsCache,
  232. logger,
  233. requester,
  234. requestsCache,
  235. responsesCache,
  236. timeouts,
  237. userAgent,
  238. headers,
  239. queryParameters,
  240. hosts: hosts.map(host => createStatelessHost(host)),
  241. read(request, requestOptions) {
  242. /**
  243. * First, we compute the user request options. Now, keep in mind,
  244. * that using request options the user is able to modified the intire
  245. * payload of the request. Such as headers, query parameters, and others.
  246. */
  247. const mappedRequestOptions = createMappedRequestOptions(requestOptions, transporter.timeouts.read);
  248. const createRetryableRequest = () => {
  249. /**
  250. * Then, we prepare a function factory that contains the construction of
  251. * the retryable request. At this point, we may *not* perform the actual
  252. * request. But we want to have the function factory ready.
  253. */
  254. return retryableRequest(transporter, transporter.hosts.filter(host => (host.accept & CallEnum.Read) !== 0), request, mappedRequestOptions);
  255. };
  256. /**
  257. * Once we have the function factory ready, we need to determine of the
  258. * request is "cacheable" - should be cached. Note that, once again,
  259. * the user can force this option.
  260. */
  261. const cacheable = mappedRequestOptions.cacheable !== undefined
  262. ? mappedRequestOptions.cacheable
  263. : request.cacheable;
  264. /**
  265. * If is not "cacheable", we immediatly trigger the retryable request, no
  266. * need to check cache implementations.
  267. */
  268. if (cacheable !== true) {
  269. return createRetryableRequest();
  270. }
  271. /**
  272. * If the request is "cacheable", we need to first compute the key to ask
  273. * the cache implementations if this request is on progress or if the
  274. * response already exists on the cache.
  275. */
  276. const key = {
  277. request,
  278. mappedRequestOptions,
  279. transporter: {
  280. queryParameters: transporter.queryParameters,
  281. headers: transporter.headers,
  282. },
  283. };
  284. /**
  285. * With the computed key, we first ask the responses cache
  286. * implemention if this request was been resolved before.
  287. */
  288. return transporter.responsesCache.get(key, () => {
  289. /**
  290. * If the request has never resolved before, we actually ask if there
  291. * is a current request with the same key on progress.
  292. */
  293. return transporter.requestsCache.get(key, () => {
  294. return (transporter.requestsCache
  295. /**
  296. * Finally, if there is no request in progress with the same key,
  297. * this `createRetryableRequest()` will actually trigger the
  298. * retryable request.
  299. */
  300. .set(key, createRetryableRequest())
  301. .then(response => Promise.all([transporter.requestsCache.delete(key), response]), err => Promise.all([transporter.requestsCache.delete(key), Promise.reject(err)]))
  302. .then(([_, response]) => response));
  303. });
  304. }, {
  305. /**
  306. * Of course, once we get this response back from the server, we
  307. * tell response cache to actually store the received response
  308. * to be used later.
  309. */
  310. miss: response => transporter.responsesCache.set(key, response),
  311. });
  312. },
  313. write(request, requestOptions) {
  314. /**
  315. * On write requests, no cache mechanisms are applied, and we
  316. * proxy the request immediately to the requester.
  317. */
  318. return retryableRequest(transporter, transporter.hosts.filter(host => (host.accept & CallEnum.Write) !== 0), request, createMappedRequestOptions(requestOptions, transporter.timeouts.write));
  319. },
  320. };
  321. return transporter;
  322. }
  323. function createUserAgent(version) {
  324. const userAgent = {
  325. value: `Algolia for JavaScript (${version})`,
  326. add(options) {
  327. const addedUserAgent = `; ${options.segment}${options.version !== undefined ? ` (${options.version})` : ''}`;
  328. if (userAgent.value.indexOf(addedUserAgent) === -1) {
  329. // eslint-disable-next-line functional/immutable-data
  330. userAgent.value = `${userAgent.value}${addedUserAgent}`;
  331. }
  332. return userAgent;
  333. },
  334. };
  335. return userAgent;
  336. }
  337. function deserializeSuccess(response) {
  338. // eslint-disable-next-line functional/no-try-statement
  339. try {
  340. return JSON.parse(response.content);
  341. }
  342. catch (e) {
  343. throw createDeserializationError(e.message, response);
  344. }
  345. }
  346. function deserializeFailure({ content, status }, stackFrame) {
  347. // eslint-disable-next-line functional/no-let
  348. let message = content;
  349. // eslint-disable-next-line functional/no-try-statement
  350. try {
  351. message = JSON.parse(content).message;
  352. }
  353. catch (e) {
  354. // ..
  355. }
  356. return createApiError(message, status, stackFrame);
  357. }
  358. // eslint-disable-next-line functional/prefer-readonly-type
  359. function encode(format, ...args) {
  360. // eslint-disable-next-line functional/no-let
  361. let i = 0;
  362. return format.replace(/%s/g, () => encodeURIComponent(args[i++]));
  363. }
  364. function serializeUrl(host, path, queryParameters) {
  365. const queryParametersAsString = serializeQueryParameters(queryParameters);
  366. // eslint-disable-next-line functional/no-let
  367. let url = `${host.protocol}://${host.url}/${path.charAt(0) === '/' ? path.substr(1) : path}`;
  368. if (queryParametersAsString.length) {
  369. url += `?${queryParametersAsString}`;
  370. }
  371. return url;
  372. }
  373. function serializeQueryParameters(parameters) {
  374. const isObjectOrArray = (value) => Object.prototype.toString.call(value) === '[object Object]' ||
  375. Object.prototype.toString.call(value) === '[object Array]';
  376. return Object.keys(parameters)
  377. .map(key => encode('%s=%s', key, isObjectOrArray(parameters[key]) ? JSON.stringify(parameters[key]) : parameters[key]))
  378. .join('&');
  379. }
  380. function serializeData(request, requestOptions) {
  381. if (request.method === requesterCommon.MethodEnum.Get ||
  382. (request.data === undefined && requestOptions.data === undefined)) {
  383. return undefined;
  384. }
  385. const data = Array.isArray(request.data)
  386. ? request.data
  387. : { ...request.data, ...requestOptions.data };
  388. return JSON.stringify(data);
  389. }
  390. function serializeHeaders(transporter, requestOptions) {
  391. const headers = {
  392. ...transporter.headers,
  393. ...requestOptions.headers,
  394. };
  395. const serializedHeaders = {};
  396. Object.keys(headers).forEach(header => {
  397. const value = headers[header];
  398. // @ts-ignore
  399. // eslint-disable-next-line functional/immutable-data
  400. serializedHeaders[header.toLowerCase()] = value;
  401. });
  402. return serializedHeaders;
  403. }
  404. function stackTraceWithoutCredentials(stackTrace) {
  405. return stackTrace.map(stackFrame => stackFrameWithoutCredentials(stackFrame));
  406. }
  407. function stackFrameWithoutCredentials(stackFrame) {
  408. const modifiedHeaders = stackFrame.request.headers['x-algolia-api-key']
  409. ? { 'x-algolia-api-key': '*****' }
  410. : {};
  411. return {
  412. ...stackFrame,
  413. request: {
  414. ...stackFrame.request,
  415. headers: {
  416. ...stackFrame.request.headers,
  417. ...modifiedHeaders,
  418. },
  419. },
  420. };
  421. }
  422. function createApiError(message, status, transporterStackTrace) {
  423. return {
  424. name: 'ApiError',
  425. message,
  426. status,
  427. transporterStackTrace,
  428. };
  429. }
  430. function createDeserializationError(message, response) {
  431. return {
  432. name: 'DeserializationError',
  433. message,
  434. response,
  435. };
  436. }
  437. function createRetryError(transporterStackTrace) {
  438. return {
  439. name: 'RetryError',
  440. message: 'Unreachable hosts - your application id may be incorrect. If the error persists, contact support@algolia.com.',
  441. transporterStackTrace,
  442. };
  443. }
  444. exports.CallEnum = CallEnum;
  445. exports.HostStatusEnum = HostStatusEnum;
  446. exports.createApiError = createApiError;
  447. exports.createDeserializationError = createDeserializationError;
  448. exports.createMappedRequestOptions = createMappedRequestOptions;
  449. exports.createRetryError = createRetryError;
  450. exports.createStatefulHost = createStatefulHost;
  451. exports.createStatelessHost = createStatelessHost;
  452. exports.createTransporter = createTransporter;
  453. exports.createUserAgent = createUserAgent;
  454. exports.deserializeFailure = deserializeFailure;
  455. exports.deserializeSuccess = deserializeSuccess;
  456. exports.isStatefulHostTimeouted = isStatefulHostTimeouted;
  457. exports.isStatefulHostUp = isStatefulHostUp;
  458. exports.serializeData = serializeData;
  459. exports.serializeHeaders = serializeHeaders;
  460. exports.serializeQueryParameters = serializeQueryParameters;
  461. exports.serializeUrl = serializeUrl;
  462. exports.stackFrameWithoutCredentials = stackFrameWithoutCredentials;
  463. exports.stackTraceWithoutCredentials = stackTraceWithoutCredentials;