stripe
Version:
Stripe API wrapper
450 lines (449 loc) • 22 kB
JavaScript
import { StripeAPIError, StripeAuthenticationError, StripeConnectionError, StripeError, StripePermissionError, StripeRateLimitError, generateV1Error, generateV2Error, } from './Error.js';
import { HttpClient } from './net/HttpClient.js';
import { emitWarning, jsonStringifyRequestData, normalizeHeaders, queryStringifyRequestData, removeNullish, getAPIMode, getOptionsFromArgs, getDataFromArgs, parseHttpHeaderAsString, parseHttpHeaderAsNumber, } from './utils.js';
const MAX_RETRY_AFTER_WAIT = 60;
export class RequestSender {
constructor(stripe, maxBufferedRequestMetric) {
this._stripe = stripe;
this._maxBufferedRequestMetric = maxBufferedRequestMetric;
}
_addHeadersDirectlyToObject(obj, headers) {
// For convenience, make some headers easily accessible on
// lastResponse.
// NOTE: Stripe responds with lowercase header names/keys.
obj.requestId = headers['request-id'];
obj.stripeAccount = obj.stripeAccount || headers['stripe-account'];
obj.apiVersion = obj.apiVersion || headers['stripe-version'];
obj.idempotencyKey = obj.idempotencyKey || headers['idempotency-key'];
}
_makeResponseEvent(requestEvent, statusCode, headers) {
const requestEndTime = Date.now();
const requestDurationMs = requestEndTime - requestEvent.request_start_time;
return removeNullish({
api_version: headers['stripe-version'],
account: headers['stripe-account'],
idempotency_key: headers['idempotency-key'],
method: requestEvent.method,
path: requestEvent.path,
status: statusCode,
request_id: this._getRequestId(headers),
elapsed: requestDurationMs,
request_start_time: requestEvent.request_start_time,
request_end_time: requestEndTime,
});
}
_getRequestId(headers) {
return headers['request-id'];
}
/**
* Used by methods with spec.streaming === true. For these methods, we do not
* buffer successful responses into memory or do parse them into stripe
* objects, we delegate that all of that to the user and pass back the raw
* http.Response object to the callback.
*
* (Unsuccessful responses shouldn't make it here, they should
* still be buffered/parsed and handled by _jsonResponseHandler -- see
* makeRequest)
*/
_streamingResponseHandler(requestEvent, usage, callback) {
return (res) => {
const headers = res.getHeaders();
const streamCompleteCallback = () => {
const responseEvent = this._makeResponseEvent(requestEvent, res.getStatusCode(), headers);
this._stripe._emitter.emit('response', responseEvent);
this._recordRequestMetrics(this._getRequestId(headers), responseEvent.elapsed, usage);
};
const stream = res.toStream(streamCompleteCallback);
// This is here for backwards compatibility, as the stream is a raw
// HTTP response in Node and the legacy behavior was to mutate this
// response.
this._addHeadersDirectlyToObject(stream, headers);
return callback(null, stream);
};
}
/**
* Default handler for Stripe responses. Buffers the response into memory,
* parses the JSON and returns it (i.e. passes it to the callback) if there
* is no "error" field. Otherwise constructs/passes an appropriate Error.
*/
_jsonResponseHandler(requestEvent, apiMode, usage, callback) {
return (res) => {
const headers = res.getHeaders();
const requestId = this._getRequestId(headers);
const statusCode = res.getStatusCode();
const responseEvent = this._makeResponseEvent(requestEvent, statusCode, headers);
this._stripe._emitter.emit('response', responseEvent);
res
.toJSON()
.then((jsonResponse) => {
if (jsonResponse.error) {
let err;
// Convert OAuth error responses into a standard format
// so that the rest of the error logic can be shared
if (typeof jsonResponse.error === 'string') {
jsonResponse.error = {
type: jsonResponse.error,
message: jsonResponse.error_description,
};
}
jsonResponse.error.headers = headers;
jsonResponse.error.statusCode = statusCode;
jsonResponse.error.requestId = requestId;
if (statusCode === 401) {
err = new StripeAuthenticationError(jsonResponse.error);
}
else if (statusCode === 403) {
err = new StripePermissionError(jsonResponse.error);
}
else if (statusCode === 429) {
err = new StripeRateLimitError(jsonResponse.error);
}
else if (apiMode === 'v2') {
err = generateV2Error(jsonResponse.error);
}
else {
err = generateV1Error(jsonResponse.error);
}
throw err;
}
return jsonResponse;
}, (e) => {
throw new StripeAPIError({
message: 'Invalid JSON received from the Stripe API',
exception: e,
requestId: headers['request-id'],
});
})
.then((jsonResponse) => {
this._recordRequestMetrics(requestId, responseEvent.elapsed, usage);
// Expose raw response object.
const rawResponse = res.getRawResponse();
this._addHeadersDirectlyToObject(rawResponse, headers);
Object.defineProperty(jsonResponse, 'lastResponse', {
enumerable: false,
writable: false,
value: rawResponse,
});
callback(null, jsonResponse);
}, (e) => callback(e, null));
};
}
static _generateConnectionErrorMessage(requestRetries) {
return `An error occurred with our connection to Stripe.${requestRetries > 0 ? ` Request was retried ${requestRetries} times.` : ''}`;
}
// For more on when and how to retry API requests, see https://stripe.com/docs/error-handling#safely-retrying-requests-with-idempotency
static _shouldRetry(res, numRetries, maxRetries, error) {
if (error &&
numRetries === 0 &&
HttpClient.CONNECTION_CLOSED_ERROR_CODES.includes(error.code)) {
return true;
}
// Do not retry if we are out of retries.
if (numRetries >= maxRetries) {
return false;
}
// Retry on connection error.
if (!res) {
return true;
}
// The API may ask us not to retry (e.g., if doing so would be a no-op)
// or advise us to retry (e.g., in cases of lock timeouts); we defer to that.
if (res.getHeaders()['stripe-should-retry'] === 'false') {
return false;
}
if (res.getHeaders()['stripe-should-retry'] === 'true') {
return true;
}
// Retry on conflict errors.
if (res.getStatusCode() === 409) {
return true;
}
// Retry on 500, 503, and other internal errors.
//
// Note that we expect the stripe-should-retry header to be false
// in most cases when a 500 is returned, since our idempotency framework
// would typically replay it anyway.
if (res.getStatusCode() >= 500) {
return true;
}
return false;
}
_getSleepTimeInMS(numRetries, retryAfter = null) {
const initialNetworkRetryDelay = this._stripe.getInitialNetworkRetryDelay();
const maxNetworkRetryDelay = this._stripe.getMaxNetworkRetryDelay();
// Apply exponential backoff with initialNetworkRetryDelay on the
// number of numRetries so far as inputs. Do not allow the number to exceed
// maxNetworkRetryDelay.
let sleepSeconds = Math.min(initialNetworkRetryDelay * Math.pow(2, numRetries - 1), maxNetworkRetryDelay);
// Apply some jitter by randomizing the value in the range of
// (sleepSeconds / 2) to (sleepSeconds).
sleepSeconds *= 0.5 * (1 + Math.random());
// But never sleep less than the base sleep seconds.
sleepSeconds = Math.max(initialNetworkRetryDelay, sleepSeconds);
// And never sleep less than the time the API asks us to wait, assuming it's a reasonable ask.
if (Number.isInteger(retryAfter) && retryAfter <= MAX_RETRY_AFTER_WAIT) {
sleepSeconds = Math.max(sleepSeconds, retryAfter);
}
return sleepSeconds * 1000;
}
// Max retries can be set on a per request basis. Favor those over the global setting
_getMaxNetworkRetries(settings = {}) {
return settings.maxNetworkRetries !== undefined &&
Number.isInteger(settings.maxNetworkRetries)
? settings.maxNetworkRetries
: this._stripe.getMaxNetworkRetries();
}
_defaultIdempotencyKey(method, settings, apiMode) {
// If this is a POST and we allow multiple retries, ensure an idempotency key.
const maxRetries = this._getMaxNetworkRetries(settings);
const genKey = () => `stripe-node-retry-${this._stripe._platformFunctions.uuid4()}`;
// more verbose than it needs to be, but gives clear separation between V1 and V2 behavior
if (apiMode === 'v2') {
if (method === 'POST' || method === 'DELETE') {
return genKey();
}
}
else if (apiMode === 'v1') {
if (method === 'POST' && maxRetries > 0) {
return genKey();
}
}
return null;
}
_makeHeaders({ contentType, contentLength, apiVersion, clientUserAgent, method, userSuppliedHeaders, userSuppliedSettings, stripeAccount, stripeContext, apiMode, }) {
const defaultHeaders = {
Accept: 'application/json',
'Content-Type': contentType,
'User-Agent': this._getUserAgentString(apiMode),
'X-Stripe-Client-User-Agent': clientUserAgent,
'X-Stripe-Client-Telemetry': this._getTelemetryHeader(),
'Stripe-Version': apiVersion,
'Stripe-Account': stripeAccount,
'Stripe-Context': stripeContext,
'Idempotency-Key': this._defaultIdempotencyKey(method, userSuppliedSettings, apiMode),
};
// As per https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.2:
// A user agent SHOULD send a Content-Length in a request message when
// no Transfer-Encoding is sent and the request method defines a meaning
// for an enclosed payload body. For example, a Content-Length header
// field is normally sent in a POST request even when the value is 0
// (indicating an empty payload body). A user agent SHOULD NOT send a
// Content-Length header field when the request message does not contain
// a payload body and the method semantics do not anticipate such a
// body.
//
// These method types are expected to have bodies and so we should always
// include a Content-Length.
const methodHasPayload = method == 'POST' || method == 'PUT' || method == 'PATCH';
// If a content length was specified, we always include it regardless of
// whether the method semantics anticipate such a body. This keeps us
// consistent with historical behavior. We do however want to warn on this
// and fix these cases as they are semantically incorrect.
if (methodHasPayload || contentLength) {
if (!methodHasPayload) {
emitWarning(`${method} method had non-zero contentLength but no payload is expected for this verb`);
}
defaultHeaders['Content-Length'] = contentLength;
}
return Object.assign(removeNullish(defaultHeaders),
// If the user supplied, say 'idempotency-key', override instead of appending by ensuring caps are the same.
normalizeHeaders(userSuppliedHeaders));
}
_getUserAgentString(apiMode) {
const packageVersion = this._stripe.getConstant('PACKAGE_VERSION');
const appInfo = this._stripe._appInfo
? this._stripe.getAppInfoAsString()
: '';
return `Stripe/${apiMode} NodeBindings/${packageVersion} ${appInfo}`.trim();
}
_getTelemetryHeader() {
if (this._stripe.getTelemetryEnabled() &&
this._stripe._prevRequestMetrics.length > 0) {
const metrics = this._stripe._prevRequestMetrics.shift();
return JSON.stringify({
last_request_metrics: metrics,
});
}
}
_recordRequestMetrics(requestId, requestDurationMs, usage) {
if (this._stripe.getTelemetryEnabled() && requestId) {
if (this._stripe._prevRequestMetrics.length > this._maxBufferedRequestMetric) {
emitWarning('Request metrics buffer is full, dropping telemetry message.');
}
else {
const m = {
request_id: requestId,
request_duration_ms: requestDurationMs,
};
if (usage && usage.length > 0) {
m.usage = usage;
}
this._stripe._prevRequestMetrics.push(m);
}
}
}
_rawRequest(method, path, params, options) {
const requestPromise = new Promise((resolve, reject) => {
let opts;
try {
const requestMethod = method.toUpperCase();
if (requestMethod !== 'POST' &&
params &&
Object.keys(params).length !== 0) {
throw new Error('rawRequest only supports params on POST requests. Please pass null and add your parameters to path.');
}
const args = [].slice.call([params, options]);
// Pull request data and options (headers, auth) from args.
const dataFromArgs = getDataFromArgs(args);
const data = requestMethod === 'POST' ? Object.assign({}, dataFromArgs) : null;
const calculatedOptions = getOptionsFromArgs(args);
const headers = calculatedOptions.headers;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const authenticator = calculatedOptions.authenticator;
opts = {
requestMethod,
requestPath: path,
bodyData: data,
queryData: {},
authenticator,
headers,
host: calculatedOptions.host,
streaming: !!calculatedOptions.streaming,
settings: {},
usage: ['raw_request'],
};
}
catch (err) {
reject(err);
return;
}
function requestCallback(err, response) {
if (err) {
reject(err);
}
else {
resolve(response);
}
}
const { headers, settings } = opts;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const authenticator = opts.authenticator;
this._request(opts.requestMethod, opts.host, path, opts.bodyData, authenticator, { headers, settings, streaming: opts.streaming }, opts.usage, requestCallback);
});
return requestPromise;
}
_request(method, host, path, data, authenticator, options, usage = [], callback, requestDataProcessor = null) {
var _a;
let requestData;
authenticator = (_a = authenticator !== null && authenticator !== void 0 ? authenticator : this._stripe._authenticator) !== null && _a !== void 0 ? _a : null;
const apiMode = getAPIMode(path);
const retryRequest = (requestFn, apiVersion, headers, requestRetries, retryAfter) => {
return setTimeout(requestFn, this._getSleepTimeInMS(requestRetries, retryAfter), apiVersion, headers, requestRetries + 1);
};
const makeRequest = (apiVersion, headers, numRetries) => {
// timeout can be set on a per-request basis. Favor that over the global setting
const timeout = options.settings &&
options.settings.timeout &&
Number.isInteger(options.settings.timeout) &&
options.settings.timeout >= 0
? options.settings.timeout
: this._stripe.getApiField('timeout');
const request = {
host: host || this._stripe.getApiField('host'),
port: this._stripe.getApiField('port'),
path: path,
method: method,
headers: Object.assign({}, headers),
body: requestData,
protocol: this._stripe.getApiField('protocol'),
};
authenticator(request)
.then(() => {
const req = this._stripe
.getApiField('httpClient')
.makeRequest(request.host, request.port, request.path, request.method, request.headers, request.body, request.protocol, timeout);
const requestStartTime = Date.now();
const requestEvent = removeNullish({
api_version: apiVersion,
account: parseHttpHeaderAsString(headers['Stripe-Account']),
idempotency_key: parseHttpHeaderAsString(headers['Idempotency-Key']),
method,
path,
request_start_time: requestStartTime,
});
const requestRetries = numRetries || 0;
const maxRetries = this._getMaxNetworkRetries(options.settings || {});
this._stripe._emitter.emit('request', requestEvent);
req
.then((res) => {
if (RequestSender._shouldRetry(res, requestRetries, maxRetries)) {
return retryRequest(makeRequest, apiVersion, headers, requestRetries, parseHttpHeaderAsNumber(res.getHeaders()['retry-after']));
}
else if (options.streaming && res.getStatusCode() < 400) {
return this._streamingResponseHandler(requestEvent, usage, callback)(res);
}
else {
return this._jsonResponseHandler(requestEvent, apiMode, usage, callback)(res);
}
})
.catch((error) => {
if (RequestSender._shouldRetry(null, requestRetries, maxRetries, error)) {
return retryRequest(makeRequest, apiVersion, headers, requestRetries, null);
}
else {
const isTimeoutError = error.code && error.code === HttpClient.TIMEOUT_ERROR_CODE;
return callback(new StripeConnectionError({
message: isTimeoutError
? `Request aborted due to timeout being reached (${timeout}ms)`
: RequestSender._generateConnectionErrorMessage(requestRetries),
detail: error,
}));
}
});
})
.catch((e) => {
throw new StripeError({
message: 'Unable to authenticate the request',
exception: e,
});
});
};
const prepareAndMakeRequest = (error, data) => {
if (error) {
return callback(error);
}
requestData = data;
this._stripe.getClientUserAgent((clientUserAgent) => {
const apiVersion = this._stripe.getApiField('version');
const headers = this._makeHeaders({
contentType: apiMode == 'v2'
? 'application/json'
: 'application/x-www-form-urlencoded',
contentLength: requestData.length,
apiVersion: apiVersion,
clientUserAgent,
method,
userSuppliedHeaders: options.headers,
userSuppliedSettings: options.settings,
stripeAccount: apiMode == 'v2' ? null : this._stripe.getApiField('stripeAccount'),
stripeContext: apiMode == 'v2' ? this._stripe.getApiField('stripeContext') : null,
apiMode: apiMode,
});
makeRequest(apiVersion, headers, 0);
});
};
if (requestDataProcessor) {
requestDataProcessor(method, data, options.headers, prepareAndMakeRequest);
}
else {
let stringifiedData;
if (apiMode == 'v2') {
stringifiedData = data ? jsonStringifyRequestData(data) : '';
}
else {
stringifiedData = queryStringifyRequestData(data || {}, apiMode);
}
prepareAndMakeRequest(null, stringifiedData);
}
}
}