transporter.esm.js 17 KB

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