Source: api-request.ts

  1. /**
  2. * @fileoverview A Box API Request
  3. */
  4. // @NOTE(fschott) 08/05/2014: THIS FILE SHOULD NOT BE ACCESSED DIRECTLY OUTSIDE OF API-REQUEST-MANAGER
  5. // This module is used by APIRequestManager to make requests. If you'd like to make requests to the
  6. // Box API, consider using APIRequestManager instead. {@Link APIRequestManager}
  7. // ------------------------------------------------------------------------------
  8. // Requirements
  9. // ------------------------------------------------------------------------------
  10. import assert from 'assert';
  11. import { EventEmitter } from 'events';
  12. import httpStatusCodes from 'http-status';
  13. import Config from './util/config';
  14. import getRetryTimeout from './util/exponential-backoff';
  15. const request = require('@cypress/request');
  16. // ------------------------------------------------------------------------------
  17. // Typedefs and Callbacks
  18. // ------------------------------------------------------------------------------
  19. // @NOTE(fschott) 08-19-2014: We cannot return the request/response objects directly because they contain loads of extra
  20. // information, unnecessary bloat, circular dependencies, and cause an infinite loop when stringifying.
  21. /**
  22. * The API response object includes information about the request made and its response. The information attached is a subset
  23. * of the information returned by the request module, which is too large and complex to be safely handled (contains circular
  24. * references, errors on serialization, etc.)
  25. *
  26. * @typedef {Object} APIRequest~ResponseObject
  27. * @property {APIRequest~RequestObject} request Information about the request that generated this response
  28. * @property {int} statusCode The response HTTP status code
  29. * @property {Object} headers A collection of response headers
  30. * @property {Object|Buffer|string} [body] The response body. Encoded to JSON by default, but can be a buffer
  31. * (if encoding fails or if json encoding is disabled) or a string (if string encoding is enabled). Will be undefined
  32. * if no response body is sent.
  33. */
  34. type APIRequestResponseObject = {
  35. request: APIRequestRequestObject;
  36. statusCode: number;
  37. headers: Record<string, string>;
  38. body?: object | Buffer | string;
  39. };
  40. // @NOTE(fschott) 08-19-2014: We cannot return the request/response objects directly because they contain loads of extra
  41. // information, unnecessary bloat, circular dependencies, and cause an infinite loop when stringifying.
  42. /**
  43. * The API request object includes information about the request made. The information attached is a subset of the information
  44. * of a request module instance, which is too large and complex to be safely handled (contains circular references, errors on
  45. * serialization, etc.).
  46. *
  47. * @typedef {Object} APIRequest~RequestObject
  48. * @property {Object} uri Information about the request, including host, path, and the full 'href' url
  49. * @property {string} method The request method (GET, POST, etc.)
  50. * @property {Object} headers A collection of headers sent with the request
  51. */
  52. type APIRequestRequestObject = {
  53. uri: Record<string, any>;
  54. method: string;
  55. headers: Record<string, string>;
  56. };
  57. /**
  58. * The error returned by APIRequest callbacks, which includes any relevent, available information about the request
  59. * and response. Note that these properties do not exist on stream errors, only errors retuned to the callback.
  60. *
  61. * @typedef {Error} APIRequest~Error
  62. * @property {APIRequest~RequestObject} request Information about the request that generated this error
  63. * @property {APIRequest~ResponseObject} [response] Information about the response related to this error, if available
  64. * @property {int} [statusCode] The response HTTP status code
  65. * @property {boolean} [maxRetriesExceeded] True iff the max number of retries were exceeded. Otherwise, undefined.
  66. */
  67. type APIRequestError = {
  68. request: APIRequestRequestObject;
  69. response?: APIRequestResponseObject;
  70. statusCode?: number;
  71. maxRetriesExceeded?: boolean;
  72. };
  73. /**
  74. * Callback invoked when an APIRequest request is complete and finalized. On success,
  75. * propagates the relevent response information. An err will indicate an unresolvable issue
  76. * with the request (permanent failure or temp error response from the server, retried too many times).
  77. *
  78. * @callback APIRequest~Callback
  79. * @param {?APIRequest~Error} err If Error object, API request did not get back the data it was supposed to. This
  80. * could be either because of a temporary error, or a more serious error connecting to the API.
  81. * @param {APIRequest~ResponseObject} response The response returned by an APIRequestManager request
  82. */
  83. type APIRequestCallback = (
  84. err?: APIRequestError | null,
  85. response?: APIRequestResponseObject
  86. ) => void;
  87. // ------------------------------------------------------------------------------
  88. // Private
  89. // ------------------------------------------------------------------------------
  90. // Message to replace removed headers with in the request
  91. var REMOVED_HEADER_MESSAGE = '[REMOVED BY SDK]';
  92. // Range of SERVER ERROR http status codes
  93. var HTTP_STATUS_CODE_SERVER_ERROR_BLOCK_RANGE = [500, 599];
  94. // Timer used to track elapsed time beginning from executing an async request to emitting the response.
  95. var asyncRequestTimer: [number, number];
  96. // A map of HTTP status codes and whether or not they can be retried
  97. var retryableStatusCodes: Record<number, boolean> = {};
  98. retryableStatusCodes[httpStatusCodes.REQUEST_TIMEOUT] = true;
  99. retryableStatusCodes[httpStatusCodes.TOO_MANY_REQUESTS] = true;
  100. /**
  101. * Returns true if the response info indicates a temporary/transient error.
  102. *
  103. * @param {?APIRequest~ResponseObject} response The response info from an API request,
  104. * or undefined if the API request did not return any response info.
  105. * @returns {boolean} True if the API call error is temporary (and hence can
  106. * be retried). False otherwise.
  107. * @private
  108. */
  109. function isTemporaryError(response: APIRequestResponseObject) {
  110. var statusCode = response.statusCode;
  111. // An API error is a temporary/transient if it returns a 5xx HTTP Status, with the exception of the 507 status.
  112. // The API returns a 507 error when the user has run out of account space, in which case, it should be treated
  113. // as a permanent, non-retryable error.
  114. if (
  115. statusCode !== httpStatusCodes.INSUFFICIENT_STORAGE &&
  116. statusCode >= HTTP_STATUS_CODE_SERVER_ERROR_BLOCK_RANGE[0] &&
  117. statusCode <= HTTP_STATUS_CODE_SERVER_ERROR_BLOCK_RANGE[1]
  118. ) {
  119. return true;
  120. }
  121. // An API error is a temporary/transient error if it returns a HTTP Status that indicates it is a temporary,
  122. if (retryableStatusCodes[statusCode]) {
  123. return true;
  124. }
  125. return false;
  126. }
  127. function isClientErrorResponse(response: { statusCode: number }) {
  128. if (!response || typeof response !== 'object') {
  129. throw new Error(
  130. `Expecting response to be an object, got: ${String(response)}`
  131. );
  132. }
  133. const { statusCode } = response;
  134. if (typeof statusCode !== 'number') {
  135. throw new Error(
  136. `Expecting status code of response to be a number, got: ${String(
  137. statusCode
  138. )}`
  139. );
  140. }
  141. return 400 <= statusCode && statusCode < 500;
  142. }
  143. function createErrorForResponse(response: { statusCode: number }): Error {
  144. var errorMessage = `${response.statusCode} - ${
  145. (httpStatusCodes as any)[response.statusCode]
  146. }`;
  147. return new Error(errorMessage);
  148. }
  149. /**
  150. * Determine whether a given request can be retried, based on its options
  151. * @param {Object} options The request options
  152. * @returns {boolean} Whether or not the request is retryable
  153. * @private
  154. */
  155. function isRequestRetryable(options: Record<string, any>) {
  156. return !options.formData;
  157. }
  158. /**
  159. * Clean sensitive headers from the request object. This prevents this data from
  160. * propagating out to the SDK and getting unintentionally logged via the error or
  161. * response objects. Note that this function modifies the given object and returns
  162. * nothing.
  163. *
  164. * @param {APIRequest~RequestObject} requestObj Any request object
  165. * @returns {void}
  166. * @private
  167. */
  168. function cleanSensitiveHeaders(requestObj: APIRequestRequestObject) {
  169. if (requestObj.headers) {
  170. if (requestObj.headers.BoxApi) {
  171. requestObj.headers.BoxApi = REMOVED_HEADER_MESSAGE;
  172. }
  173. if (requestObj.headers.Authorization) {
  174. requestObj.headers.Authorization = REMOVED_HEADER_MESSAGE;
  175. }
  176. }
  177. }
  178. // ------------------------------------------------------------------------------
  179. // Public
  180. // ------------------------------------------------------------------------------
  181. /**
  182. * APIRequest helps to prepare and execute requests to the Box API. It supports
  183. * retries, multipart uploads, and more.
  184. *
  185. * @param {Config} config Request-specific Config object
  186. * @param {EventEmitter} eventBus Event bus for the SDK instance
  187. * @constructor
  188. */
  189. class APIRequest {
  190. config: Config;
  191. eventBus: EventEmitter;
  192. isRetryable: boolean;
  193. _callback?: APIRequestCallback;
  194. request?: any; // request.Request;
  195. stream?: any; // request.Request;
  196. numRetries?: number;
  197. constructor(config: Config, eventBus: EventEmitter) {
  198. assert(
  199. config instanceof Config,
  200. 'Config must be passed to APIRequest constructor'
  201. );
  202. assert(
  203. eventBus instanceof EventEmitter,
  204. 'Valid event bus must be passed to APIRequest constructor'
  205. );
  206. this.config = config;
  207. this.eventBus = eventBus;
  208. this.isRetryable = isRequestRetryable(config.request);
  209. }
  210. /**
  211. * Executes the request with the given options. If a callback is provided, we'll
  212. * handle the response via callbacks. Otherwise, the response will be streamed to
  213. * via the stream property. You can access this stream with the getResponseStream()
  214. * method.
  215. *
  216. * @param {APIRequest~Callback} [callback] Callback for handling the response
  217. * @returns {void}
  218. */
  219. execute(callback?: APIRequestCallback) {
  220. this._callback = callback || this._callback;
  221. // Initiate an async- or stream-based request, based on the presence of the callback.
  222. if (this._callback) {
  223. // Start the request timer immediately before executing the async request
  224. if (!asyncRequestTimer) {
  225. asyncRequestTimer = process.hrtime();
  226. }
  227. this.request = request(
  228. this.config.request,
  229. this._handleResponse.bind(this)
  230. );
  231. } else {
  232. this.request = request(this.config.request);
  233. this.stream = this.request;
  234. this.stream.on('error', (err: any) => {
  235. this.eventBus.emit('response', err);
  236. });
  237. this.stream.on('response', (response: any) => {
  238. if (isClientErrorResponse(response)) {
  239. this.eventBus.emit('response', createErrorForResponse(response));
  240. return;
  241. }
  242. this.eventBus.emit('response', null, response);
  243. });
  244. }
  245. }
  246. /**
  247. * Return the response read stream for a request. This will be undefined until
  248. * a stream-based request has been started.
  249. *
  250. * @returns {?ReadableStream} The response stream
  251. */
  252. getResponseStream() {
  253. return this.stream;
  254. }
  255. /**
  256. * Handle the request response in the callback case.
  257. *
  258. * @param {?Error} err An error, if one occurred
  259. * @param {Object} [response] The full response object, returned by the request module.
  260. * Contains information about the request & response, including the response body itself.
  261. * @returns {void}
  262. * @private
  263. */
  264. _handleResponse(err?: any /* FIXME */, response?: any /* FIXME */) {
  265. // Clean sensitive headers here to prevent the user from accidentily using/logging them in prod
  266. cleanSensitiveHeaders(this.request!);
  267. // If the API connected successfully but responded with a temporary error (like a 5xx code,
  268. // a rate limited response, etc.) then this is considered an error as well.
  269. if (!err && isTemporaryError(response)) {
  270. err = createErrorForResponse(response);
  271. }
  272. if (err) {
  273. // Attach request & response information to the error object
  274. err.request = this.request;
  275. if (response) {
  276. err.response = response;
  277. err.statusCode = response.statusCode;
  278. }
  279. // Have the SDK emit the error response
  280. this.eventBus.emit('response', err);
  281. var isJWT = false;
  282. if (
  283. this.config.request.hasOwnProperty('form') &&
  284. this.config.request.form.hasOwnProperty('grant_type') &&
  285. this.config.request.form.grant_type ===
  286. 'urn:ietf:params:oauth:grant-type:jwt-bearer'
  287. ) {
  288. isJWT = true;
  289. }
  290. // If our APIRequest instance is retryable, attempt a retry. Otherwise, finish and propagate the error. Doesn't retry when the request is for JWT authentication, since that is handled in retryJWTGrant.
  291. if (this.isRetryable && !isJWT) {
  292. this._retry(err);
  293. } else {
  294. this._finish(err);
  295. }
  296. return;
  297. }
  298. // If the request was successful, emit & propagate the response!
  299. this.eventBus.emit('response', null, response);
  300. this._finish(null, response);
  301. }
  302. /**
  303. * Attempt a retry. If the request hasn't exceeded it's maximum number of retries,
  304. * re-execute the request (after the retry interval). Otherwise, propagate a new error.
  305. *
  306. * @param {?Error} err An error, if one occurred
  307. * @returns {void}
  308. * @private
  309. */
  310. _retry(err?: any /* FIXME */) {
  311. this.numRetries = this.numRetries || 0;
  312. if (this.numRetries < this.config.numMaxRetries) {
  313. var retryTimeout;
  314. this.numRetries += 1;
  315. // If the retry strategy is defined, then use it to determine the time (in ms) until the next retry or to
  316. // propagate an error to the user.
  317. if (this.config.retryStrategy) {
  318. // Get the total elapsed time so far since the request was executed
  319. var totalElapsedTime = process.hrtime(asyncRequestTimer);
  320. var totalElapsedTimeMS =
  321. totalElapsedTime[0] * 1000 + totalElapsedTime[1] / 1000000;
  322. var retryOptions = {
  323. error: err,
  324. numRetryAttempts: this.numRetries,
  325. numMaxRetries: this.config.numMaxRetries,
  326. retryIntervalMS: this.config.retryIntervalMS,
  327. totalElapsedTimeMS,
  328. };
  329. retryTimeout = this.config.retryStrategy(retryOptions);
  330. // If the retry strategy doesn't return a number/time in ms, then propagate the response error to the user.
  331. // However, if the retry strategy returns its own error, this will be propagated to the user instead.
  332. if (typeof retryTimeout !== 'number') {
  333. if (retryTimeout instanceof Error) {
  334. err = retryTimeout;
  335. }
  336. this._finish(err);
  337. return;
  338. }
  339. } else if (
  340. err.hasOwnProperty('response') &&
  341. err.response.hasOwnProperty('headers') &&
  342. err.response.headers.hasOwnProperty('retry-after')
  343. ) {
  344. retryTimeout = err.response.headers['retry-after'] * 1000;
  345. } else {
  346. retryTimeout = getRetryTimeout(
  347. this.numRetries,
  348. this.config.retryIntervalMS
  349. );
  350. }
  351. setTimeout(this.execute.bind(this), retryTimeout);
  352. } else {
  353. err.maxRetriesExceeded = true;
  354. this._finish(err);
  355. }
  356. }
  357. /**
  358. * Propagate the response to the provided callback.
  359. *
  360. * @param {?Error} err An error, if one occurred
  361. * @param {APIRequest~ResponseObject} response Information about the request & response
  362. * @returns {void}
  363. * @private
  364. */
  365. _finish(err?: any, response?: APIRequestResponseObject) {
  366. var callback = this._callback!;
  367. process.nextTick(() => {
  368. if (err) {
  369. callback(err);
  370. return;
  371. }
  372. callback(null, response);
  373. });
  374. }
  375. }
  376. /**
  377. * @module box-node-sdk/lib/api-request
  378. * @see {@Link APIRequest}
  379. */
  380. export = APIRequest;