UNPKG

15.9 kBJavaScriptView Raw
1'use strict';
2
3const http = require('http');
4const https = require('https');
5const path = require('path');
6
7const utils = require('./utils');
8const {
9 StripeConnectionError,
10 StripeAuthenticationError,
11 StripePermissionError,
12 StripeRateLimitError,
13 StripeError,
14 StripeAPIError,
15} = require('./Error');
16
17const defaultHttpAgent = new http.Agent({keepAlive: true});
18const defaultHttpsAgent = new https.Agent({keepAlive: true});
19
20// Provide extension mechanism for Stripe Resource Sub-Classes
21StripeResource.extend = utils.protoExtend;
22
23// Expose method-creator & prepared (basic) methods
24StripeResource.method = require('./StripeMethod');
25StripeResource.BASIC_METHODS = require('./StripeMethod.basic');
26
27StripeResource.MAX_BUFFERED_REQUEST_METRICS = 100;
28const MAX_RETRY_AFTER_WAIT = 60;
29
30/**
31 * Encapsulates request logic for a Stripe Resource
32 */
33function StripeResource(stripe, deprecatedUrlData) {
34 this._stripe = stripe;
35 if (deprecatedUrlData) {
36 throw new Error(
37 'Support for curried url params was dropped in stripe-node v7.0.0. Instead, pass two ids.'
38 );
39 }
40
41 this.basePath = utils.makeURLInterpolator(
42 this.basePath || stripe.getApiField('basePath')
43 );
44 this.resourcePath = this.path;
45 this.path = utils.makeURLInterpolator(this.path);
46
47 if (this.includeBasic) {
48 this.includeBasic.forEach(function(methodName) {
49 this[methodName] = StripeResource.BASIC_METHODS[methodName];
50 }, this);
51 }
52
53 this.initialize(...arguments);
54}
55
56StripeResource.prototype = {
57 path: '',
58
59 // Methods that don't use the API's default '/v1' path can override it with this setting.
60 basePath: null,
61
62 initialize() {},
63
64 // Function to override the default data processor. This allows full control
65 // over how a StripeResource's request data will get converted into an HTTP
66 // body. This is useful for non-standard HTTP requests. The function should
67 // take method name, data, and headers as arguments.
68 requestDataProcessor: null,
69
70 // Function to add a validation checks before sending the request, errors should
71 // be thrown, and they will be passed to the callback/promise.
72 validateRequest: null,
73
74 createFullPath(commandPath, urlData) {
75 return path
76 .join(
77 this.basePath(urlData),
78 this.path(urlData),
79 typeof commandPath == 'function' ? commandPath(urlData) : commandPath
80 )
81 .replace(/\\/g, '/'); // ugly workaround for Windows
82 },
83
84 // Creates a relative resource path with symbols left in (unlike
85 // createFullPath which takes some data to replace them with). For example it
86 // might produce: /invoices/{id}
87 createResourcePathWithSymbols(pathWithSymbols) {
88 return `/${path
89 .join(this.resourcePath, pathWithSymbols || '')
90 .replace(/\\/g, '/')}`; // ugly workaround for Windows
91 },
92
93 // DEPRECATED: Here for backcompat in case users relied on this.
94 wrapTimeout: utils.callbackifyPromiseWithTimeout,
95
96 _timeoutHandler(timeout, req, callback) {
97 return () => {
98 const timeoutErr = new TypeError('ETIMEDOUT');
99 timeoutErr.code = 'ETIMEDOUT';
100
101 req._isAborted = true;
102 req.abort();
103
104 callback.call(
105 this,
106 new StripeConnectionError({
107 message: `Request aborted due to timeout being reached (${timeout}ms)`,
108 detail: timeoutErr,
109 }),
110 null
111 );
112 };
113 },
114
115 _responseHandler(req, callback) {
116 return (res) => {
117 let response = '';
118
119 res.setEncoding('utf8');
120 res.on('data', (chunk) => {
121 response += chunk;
122 });
123 res.once('end', () => {
124 const headers = res.headers || {};
125 // NOTE: Stripe responds with lowercase header names/keys.
126
127 // For convenience, make Request-Id easily accessible on
128 // lastResponse.
129 res.requestId = headers['request-id'];
130
131 const requestEndTime = Date.now();
132 const requestDurationMs = requestEndTime - req._requestStart;
133
134 const responseEvent = utils.removeNullish({
135 api_version: headers['stripe-version'],
136 account: headers['stripe-account'],
137 idempotency_key: headers['idempotency-key'],
138 method: req._requestEvent.method,
139 path: req._requestEvent.path,
140 status: res.statusCode,
141 request_id: res.requestId,
142 elapsed: requestDurationMs,
143 request_start_time: req._requestStart,
144 request_end_time: requestEndTime,
145 });
146
147 this._stripe._emitter.emit('response', responseEvent);
148
149 try {
150 response = JSON.parse(response);
151
152 if (response.error) {
153 let err;
154
155 // Convert OAuth error responses into a standard format
156 // so that the rest of the error logic can be shared
157 if (typeof response.error === 'string') {
158 response.error = {
159 type: response.error,
160 message: response.error_description,
161 };
162 }
163
164 response.error.headers = headers;
165 response.error.statusCode = res.statusCode;
166 response.error.requestId = res.requestId;
167
168 if (res.statusCode === 401) {
169 err = new StripeAuthenticationError(response.error);
170 } else if (res.statusCode === 403) {
171 err = new StripePermissionError(response.error);
172 } else if (res.statusCode === 429) {
173 err = new StripeRateLimitError(response.error);
174 } else {
175 err = StripeError.generate(response.error);
176 }
177 return callback.call(this, err, null);
178 }
179 } catch (e) {
180 return callback.call(
181 this,
182 new StripeAPIError({
183 message: 'Invalid JSON received from the Stripe API',
184 response,
185 exception: e,
186 requestId: headers['request-id'],
187 }),
188 null
189 );
190 }
191
192 this._recordRequestMetrics(res.requestId, requestDurationMs);
193
194 // Expose res object
195 Object.defineProperty(response, 'lastResponse', {
196 enumerable: false,
197 writable: false,
198 value: res,
199 });
200 callback.call(this, null, response);
201 });
202 };
203 },
204
205 _generateConnectionErrorMessage(requestRetries) {
206 return `An error occurred with our connection to Stripe.${
207 requestRetries > 0 ? ` Request was retried ${requestRetries} times.` : ''
208 }`;
209 },
210
211 _errorHandler(req, requestRetries, callback) {
212 return (error) => {
213 if (req._isAborted) {
214 // already handled
215 return;
216 }
217 callback.call(
218 this,
219 new StripeConnectionError({
220 message: this._generateConnectionErrorMessage(requestRetries),
221 detail: error,
222 }),
223 null
224 );
225 };
226 },
227
228 // For more on when and how to retry API requests, see https://stripe.com/docs/error-handling#safely-retrying-requests-with-idempotency
229 _shouldRetry(res, numRetries, maxRetries) {
230 // Do not retry if we are out of retries.
231 if (numRetries >= maxRetries) {
232 return false;
233 }
234
235 // Retry on connection error.
236 if (!res) {
237 return true;
238 }
239
240 // The API may ask us not to retry (e.g., if doing so would be a no-op)
241 // or advise us to retry (e.g., in cases of lock timeouts); we defer to that.
242 if (res.headers && res.headers['stripe-should-retry'] === 'false') {
243 return false;
244 }
245 if (res.headers && res.headers['stripe-should-retry'] === 'true') {
246 return true;
247 }
248
249 // Retry on conflict errors.
250 if (res.statusCode === 409) {
251 return true;
252 }
253
254 // Retry on 500, 503, and other internal errors.
255 //
256 // Note that we expect the stripe-should-retry header to be false
257 // in most cases when a 500 is returned, since our idempotency framework
258 // would typically replay it anyway.
259 if (res.statusCode >= 500) {
260 return true;
261 }
262
263 return false;
264 },
265
266 _getSleepTimeInMS(numRetries, retryAfter = null) {
267 const initialNetworkRetryDelay = this._stripe.getInitialNetworkRetryDelay();
268 const maxNetworkRetryDelay = this._stripe.getMaxNetworkRetryDelay();
269
270 // Apply exponential backoff with initialNetworkRetryDelay on the
271 // number of numRetries so far as inputs. Do not allow the number to exceed
272 // maxNetworkRetryDelay.
273 let sleepSeconds = Math.min(
274 initialNetworkRetryDelay * Math.pow(numRetries - 1, 2),
275 maxNetworkRetryDelay
276 );
277
278 // Apply some jitter by randomizing the value in the range of
279 // (sleepSeconds / 2) to (sleepSeconds).
280 sleepSeconds *= 0.5 * (1 + Math.random());
281
282 // But never sleep less than the base sleep seconds.
283 sleepSeconds = Math.max(initialNetworkRetryDelay, sleepSeconds);
284
285 // And never sleep less than the time the API asks us to wait, assuming it's a reasonable ask.
286 if (Number.isInteger(retryAfter) && retryAfter <= MAX_RETRY_AFTER_WAIT) {
287 sleepSeconds = Math.max(sleepSeconds, retryAfter);
288 }
289
290 return sleepSeconds * 1000;
291 },
292
293 // Max retries can be set on a per request basis. Favor those over the global setting
294 _getMaxNetworkRetries(settings = {}) {
295 return settings.maxNetworkRetries &&
296 Number.isInteger(settings.maxNetworkRetries)
297 ? settings.maxNetworkRetries
298 : this._stripe.getMaxNetworkRetries();
299 },
300
301 _defaultIdempotencyKey(method, settings) {
302 // If this is a POST and we allow multiple retries, ensure an idempotency key.
303 const maxRetries = this._getMaxNetworkRetries(settings);
304
305 if (method === 'POST' && maxRetries > 0) {
306 return `stripe-node-retry-${utils.uuid4()}`;
307 }
308 return null;
309 },
310
311 _makeHeaders(
312 auth,
313 contentLength,
314 apiVersion,
315 clientUserAgent,
316 method,
317 userSuppliedHeaders,
318 userSuppliedSettings
319 ) {
320 const defaultHeaders = {
321 // Use specified auth token or use default from this stripe instance:
322 Authorization: auth ? `Bearer ${auth}` : this._stripe.getApiField('auth'),
323 Accept: 'application/json',
324 'Content-Type': 'application/x-www-form-urlencoded',
325 'Content-Length': contentLength,
326 'User-Agent': this._getUserAgentString(),
327 'X-Stripe-Client-User-Agent': clientUserAgent,
328 'X-Stripe-Client-Telemetry': this._getTelemetryHeader(),
329 'Stripe-Version': apiVersion,
330 'Idempotency-Key': this._defaultIdempotencyKey(
331 method,
332 userSuppliedSettings
333 ),
334 };
335
336 return Object.assign(
337 utils.removeNullish(defaultHeaders),
338 // If the user supplied, say 'idempotency-key', override instead of appending by ensuring caps are the same.
339 utils.normalizeHeaders(userSuppliedHeaders)
340 );
341 },
342
343 _getUserAgentString() {
344 const packageVersion = this._stripe.getConstant('PACKAGE_VERSION');
345 const appInfo = this._stripe._appInfo
346 ? this._stripe.getAppInfoAsString()
347 : '';
348
349 return `Stripe/v1 NodeBindings/${packageVersion} ${appInfo}`.trim();
350 },
351
352 _getTelemetryHeader() {
353 if (
354 this._stripe.getTelemetryEnabled() &&
355 this._stripe._prevRequestMetrics.length > 0
356 ) {
357 const metrics = this._stripe._prevRequestMetrics.shift();
358 return JSON.stringify({
359 last_request_metrics: metrics,
360 });
361 }
362 },
363
364 _recordRequestMetrics(requestId, requestDurationMs) {
365 if (this._stripe.getTelemetryEnabled() && requestId) {
366 if (
367 this._stripe._prevRequestMetrics.length >
368 StripeResource.MAX_BUFFERED_REQUEST_METRICS
369 ) {
370 utils.emitWarning(
371 'Request metrics buffer is full, dropping telemetry message.'
372 );
373 } else {
374 this._stripe._prevRequestMetrics.push({
375 request_id: requestId,
376 request_duration_ms: requestDurationMs,
377 });
378 }
379 }
380 },
381
382 _request(method, host, path, data, auth, options = {}, callback) {
383 let requestData;
384
385 const retryRequest = (
386 requestFn,
387 apiVersion,
388 headers,
389 requestRetries,
390 retryAfter
391 ) => {
392 return setTimeout(
393 requestFn,
394 this._getSleepTimeInMS(requestRetries, retryAfter),
395 apiVersion,
396 headers,
397 requestRetries + 1
398 );
399 };
400
401 const makeRequest = (apiVersion, headers, numRetries) => {
402 // timeout can be set on a per-request basis. Favor that over the global setting
403 const timeout =
404 options.settings &&
405 Number.isInteger(options.settings.timeout) &&
406 options.settings.timeout >= 0
407 ? options.settings.timeout
408 : this._stripe.getApiField('timeout');
409
410 const isInsecureConnection =
411 this._stripe.getApiField('protocol') == 'http';
412 let agent = this._stripe.getApiField('agent');
413 if (agent == null) {
414 agent = isInsecureConnection ? defaultHttpAgent : defaultHttpsAgent;
415 }
416
417 const req = (isInsecureConnection ? http : https).request({
418 host: host || this._stripe.getApiField('host'),
419 port: this._stripe.getApiField('port'),
420 path,
421 method,
422 agent,
423 headers,
424 ciphers: 'DEFAULT:!aNULL:!eNULL:!LOW:!EXPORT:!SSLv2:!MD5',
425 });
426
427 const requestStartTime = Date.now();
428
429 const requestEvent = utils.removeNullish({
430 api_version: apiVersion,
431 account: headers['Stripe-Account'],
432 idempotency_key: headers['Idempotency-Key'],
433 method,
434 path,
435 request_start_time: requestStartTime,
436 });
437
438 const requestRetries = numRetries || 0;
439
440 const maxRetries = this._getMaxNetworkRetries(options.settings);
441
442 req._requestEvent = requestEvent;
443
444 req._requestStart = requestStartTime;
445
446 this._stripe._emitter.emit('request', requestEvent);
447
448 req.setTimeout(timeout, this._timeoutHandler(timeout, req, callback));
449
450 req.once('response', (res) => {
451 if (this._shouldRetry(res, requestRetries, maxRetries)) {
452 return retryRequest(
453 makeRequest,
454 apiVersion,
455 headers,
456 requestRetries,
457 ((res || {}).headers || {})['retry-after']
458 );
459 } else {
460 return this._responseHandler(req, callback)(res);
461 }
462 });
463
464 req.on('error', (error) => {
465 if (this._shouldRetry(null, requestRetries, maxRetries)) {
466 return retryRequest(
467 makeRequest,
468 apiVersion,
469 headers,
470 requestRetries,
471 null
472 );
473 } else {
474 return this._errorHandler(req, requestRetries, callback)(error);
475 }
476 });
477
478 req.once('socket', (socket) => {
479 if (socket.connecting) {
480 socket.once(
481 isInsecureConnection ? 'connect' : 'secureConnect',
482 () => {
483 // Send payload; we're safe:
484 req.write(requestData);
485 req.end();
486 }
487 );
488 } else {
489 // we're already connected
490 req.write(requestData);
491 req.end();
492 }
493 });
494 };
495
496 const prepareAndMakeRequest = (error, data) => {
497 if (error) {
498 return callback(error);
499 }
500
501 requestData = data;
502
503 this._stripe.getClientUserAgent((clientUserAgent) => {
504 const apiVersion = this._stripe.getApiField('version');
505 const headers = this._makeHeaders(
506 auth,
507 requestData.length,
508 apiVersion,
509 clientUserAgent,
510 method,
511 options.headers,
512 options.settings
513 );
514
515 makeRequest(apiVersion, headers);
516 });
517 };
518
519 if (this.requestDataProcessor) {
520 this.requestDataProcessor(
521 method,
522 data,
523 options.headers,
524 prepareAndMakeRequest
525 );
526 } else {
527 prepareAndMakeRequest(null, utils.stringifyRequestData(data || {}));
528 }
529 },
530};
531
532module.exports = StripeResource;