1 | 'use strict';
|
2 |
|
3 | const http = require('http');
|
4 | const https = require('https');
|
5 | const path = require('path');
|
6 |
|
7 | const utils = require('./utils');
|
8 | const {
|
9 | StripeConnectionError,
|
10 | StripeAuthenticationError,
|
11 | StripePermissionError,
|
12 | StripeRateLimitError,
|
13 | StripeError,
|
14 | StripeAPIError,
|
15 | } = require('./Error');
|
16 |
|
17 | const defaultHttpAgent = new http.Agent({keepAlive: true});
|
18 | const defaultHttpsAgent = new https.Agent({keepAlive: true});
|
19 |
|
20 |
|
21 | StripeResource.extend = utils.protoExtend;
|
22 |
|
23 |
|
24 | StripeResource.method = require('./StripeMethod');
|
25 | StripeResource.BASIC_METHODS = require('./StripeMethod.basic');
|
26 |
|
27 | StripeResource.MAX_BUFFERED_REQUEST_METRICS = 100;
|
28 | const MAX_RETRY_AFTER_WAIT = 60;
|
29 |
|
30 |
|
31 |
|
32 |
|
33 | function 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 |
|
56 | StripeResource.prototype = {
|
57 | path: '',
|
58 |
|
59 |
|
60 | basePath: null,
|
61 |
|
62 | initialize() {},
|
63 |
|
64 |
|
65 |
|
66 |
|
67 |
|
68 | requestDataProcessor: null,
|
69 |
|
70 |
|
71 |
|
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, '/');
|
82 | },
|
83 |
|
84 |
|
85 |
|
86 |
|
87 | createResourcePathWithSymbols(pathWithSymbols) {
|
88 | return `/${path
|
89 | .join(this.resourcePath, pathWithSymbols || '')
|
90 | .replace(/\\/g, '/')}`;
|
91 | },
|
92 |
|
93 |
|
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 |
|
126 |
|
127 |
|
128 |
|
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 |
|
156 |
|
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 |
|
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 |
|
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 |
|
229 | _shouldRetry(res, numRetries, maxRetries) {
|
230 |
|
231 | if (numRetries >= maxRetries) {
|
232 | return false;
|
233 | }
|
234 |
|
235 |
|
236 | if (!res) {
|
237 | return true;
|
238 | }
|
239 |
|
240 |
|
241 |
|
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 |
|
250 | if (res.statusCode === 409) {
|
251 | return true;
|
252 | }
|
253 |
|
254 |
|
255 |
|
256 |
|
257 |
|
258 |
|
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 |
|
271 |
|
272 |
|
273 | let sleepSeconds = Math.min(
|
274 | initialNetworkRetryDelay * Math.pow(numRetries - 1, 2),
|
275 | maxNetworkRetryDelay
|
276 | );
|
277 |
|
278 |
|
279 |
|
280 | sleepSeconds *= 0.5 * (1 + Math.random());
|
281 |
|
282 |
|
283 | sleepSeconds = Math.max(initialNetworkRetryDelay, sleepSeconds);
|
284 |
|
285 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
484 | req.write(requestData);
|
485 | req.end();
|
486 | }
|
487 | );
|
488 | } else {
|
489 |
|
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 |
|
532 | module.exports = StripeResource;
|