chargebee
Version:
A library for integrating with Chargebee.
282 lines (281 loc) • 13.4 kB
JavaScript
import { extend, callbackifyPromise, getApiURL, encodeListParams, encodeParams, serialize, getHost, log, generateUUIDv4 as uuidv4, } from './util.js';
import { buildRequestTelemetryContext, buildRequestTelemetryResult, extractHttpStatusCode, extractRequestTelemetryError, resolveChargebeeApiVersion, } from './telemetry/index.js';
import { handleResponse } from './coreCommon.js';
import { Buffer } from 'node:buffer';
import { ChargebeeZodValidationError } from './chargebeeZodValidationError.js';
import { getSchema } from './validationLoader.js';
export class RequestWrapper {
constructor(args, apiCall, envArg) {
this.getRequest = () => {
return this.request();
};
this.args = args;
this.apiCall = apiCall;
this.envArg = envArg;
this.httpHeaders = {};
if (this.apiCall.hasIdInUrl) {
RequestWrapper._validateIdParam(this.args[0]);
}
}
static _validateIdParam(idParam) {
if (!idParam || typeof idParam !== 'string' || idParam.trim().length < 1) {
throw new Error('the required id parameter missing or wrong');
}
return idParam;
}
/**
* Validate parameters against the action's Zod schema when enableValidation is true.
* Query params are validated as `params ?? {}`; body params are validated when `params` is non-null.
* Throws a descriptive error listing every validation violation.
*/
static _validateParams(params, schema, actionName) {
const result = schema.safeParse(params);
if (!result.success) {
throw new ChargebeeZodValidationError(actionName, result.error);
}
}
static parseRetryAfter(retryAfter) {
if (!retryAfter)
return null;
const seconds = parseInt(retryAfter, 10);
if (!isNaN(seconds)) {
return seconds * 1000;
}
return null;
}
_buildRequestUrl(env, urlIdParam, params) {
let path = getApiURL(env, this.apiCall.urlPrefix, this.apiCall.urlSuffix, urlIdParam);
if (this.apiCall.httpMethod === 'GET') {
let requestParams = params;
if (typeof requestParams === 'undefined' || requestParams === null) {
requestParams = {};
}
const queryParam = this.apiCall.isListReq
? encodeListParams(serialize(requestParams))
: encodeParams(serialize(requestParams));
path += '?' + queryParam;
}
return new URL(path, `${env.protocol}://${getHost(env, this.apiCall.subDomain)}${env.port ? `:${env.port}` : ''}`);
}
async request() {
let _env = {};
extend(true, _env, this.envArg);
// Class-based adapters (e.g. OpenTelemetry) keep methods on the prototype;
// deep extend only copies own enumerable properties, so preserve by reference.
if (this.envArg.telemetryAdapter !== undefined) {
_env.telemetryAdapter = this.envArg.telemetryAdapter;
}
const env = _env;
const retryConfig = Object.assign({ enabled: false, maxRetries: 3, delayMs: 200, retryOn: [500, 502, 503, 504] }, env.retryConfig);
const urlIdParam = this.apiCall.hasIdInUrl ? this.args[0] : null;
const params = this.apiCall.hasIdInUrl
? this.args[1]
: this.args[0];
let headers = this.apiCall.hasIdInUrl ? this.args[2] : this.args[1];
// Lazy-load Zod schema when enableValidation is true
if (env.enableValidation &&
this.apiCall.resourceKey &&
this.apiCall.actionName) {
const schema = await getSchema(this.apiCall.resourceKey, this.apiCall.actionName);
if (schema) {
if (this.apiCall.httpMethod === 'GET') {
RequestWrapper._validateParams(params !== null && params !== void 0 ? params : {}, schema, this.apiCall.methodName);
}
else if (params != null) {
RequestWrapper._validateParams(params, schema, this.apiCall.methodName);
}
}
}
Object.assign(this.httpHeaders, headers);
if (this.apiCall.httpMethod === 'POST' &&
!this.httpHeaders['chargebee-idempotency-key'] &&
this.apiCall.options &&
this.apiCall.options.isIdempotent &&
retryConfig.enabled) {
this.httpHeaders['chargebee-idempotency-key'] = uuidv4();
}
const telemetryAdapter = env.telemetryAdapter;
const telemetryHeaders = {};
const requestStartTime = Date.now();
const requestUrl = this._buildRequestUrl(env, urlIdParam, params);
// No telemetry adapter configured => skip all telemetry work (zero overhead).
let telemetryHandle;
if (telemetryAdapter !== undefined) {
const telemetryContext = buildRequestTelemetryContext({
resource: this.apiCall.resource,
operation: this.apiCall.methodName,
httpMethod: this.apiCall.httpMethod,
httpUrl: `${requestUrl.origin}${requestUrl.pathname}`,
serverAddress: requestUrl.hostname,
chargebeeSite: env.site,
chargebeeApiVersion: resolveChargebeeApiVersion(env.apiPath),
sdkVersion: env.clientVersion,
requestHeaders: this.httpHeaders,
});
try {
telemetryHandle = telemetryAdapter.onRequestStart(telemetryContext, telemetryHeaders);
}
catch (err) {
const message = err instanceof Error
? err.message
: 'Unknown telemetry adapter error';
log(env, {
level: 'ERROR',
message: `Telemetry adapter onRequestStart failed: ${message}. Continuing without telemetry.`,
});
telemetryHandle = undefined;
}
}
const makeRequest = async (attempt = 0) => {
let requestParams = params;
if (typeof requestParams === 'undefined' || requestParams === null) {
requestParams = {};
}
const jsonKeys = this.apiCall.jsonKeys;
let data = null;
if (this.apiCall.httpMethod !== 'GET') {
data = this.apiCall.isJsonRequest
? JSON.stringify(requestParams)
: encodeParams(requestParams, undefined, undefined, undefined, jsonKeys);
}
const requestHeaders = Object.assign(Object.assign({}, this.httpHeaders), telemetryHeaders);
if (data && data.length) {
extend(true, requestHeaders, {
'Content-Length': Buffer.byteLength(data, 'utf8'),
});
}
const contentType = this.apiCall.isJsonRequest
? 'application/json;charset=UTF-8'
: 'application/x-www-form-urlencoded; charset=utf-8';
const userAgent = `Chargebee-NodeJs-Client ${env.clientVersion}${env.userAgentSuffix ? ';' + env.userAgentSuffix : ''}`;
extend(true, requestHeaders, {
Authorization: 'Basic ' + Buffer.from(env.apiKey + ':').toString('base64'),
Accept: 'application/json',
'Content-Type': contentType,
'User-Agent': userAgent,
'Lang-Version': typeof process === 'undefined' ? '' : process.version,
});
if (attempt > 0) {
requestHeaders['X-CB-Retry-Attempt'] = attempt.toString();
}
const request = new Request(requestUrl, {
method: this.apiCall.httpMethod,
body: data || undefined,
headers: this._createHeaders(requestHeaders),
});
const resp = await this.envArg.httpClient.makeApiRequest(request, env.timeout);
return new Promise((resolve, reject) => {
handleResponse((err, response) => {
if (err)
return reject(err);
return resolve(response);
}, resp);
});
};
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const withRetry = async (retryCount, startTime) => {
var _a, _b, _c, _d, _e, _f;
try {
return await makeRequest(retryCount);
}
catch (err) {
const statusCode = (_d = (_c = (_a = err.statusCode) !== null && _a !== void 0 ? _a : (_b = err.response) === null || _b === void 0 ? void 0 : _b.statusCode) !== null && _c !== void 0 ? _c : err.http_code) !== null && _d !== void 0 ? _d : err.http_status_code;
const isRateLimitError = statusCode === 429 && retryConfig.enabled;
if (isRateLimitError) {
const headers = ((_e = err.response) === null || _e === void 0 ? void 0 : _e.headers) || err.headers || {};
const retryAfterHeader = (_f = headers['retry-after']) === null || _f === void 0 ? void 0 : _f.toLowerCase();
const parsedDelay = RequestWrapper.parseRetryAfter(retryAfterHeader);
if (!parsedDelay) {
log(env, {
level: 'ERROR',
message: `Rate limit error occurred, but no retry-after header found. Retrying in ${retryConfig.delayMs}ms.`,
});
throw err;
}
log(env, {
level: 'INFO',
message: `Rate limit error occurred. Retrying in ${parsedDelay}ms.`,
});
await delay(parsedDelay);
}
else {
const canRetry = retryConfig.enabled &&
retryConfig.retryOn.includes(statusCode) &&
retryCount < retryConfig.maxRetries;
if (!canRetry) {
log(env, {
level: 'ERROR',
message: `Request failed after ${retryCount} retries: ${err.message}`,
});
throw err;
}
let retryDelayMs = retryConfig.delayMs * Math.pow(2, retryCount) +
Math.random() * 100;
log(env, {
level: 'INFO',
message: `Retrying request [${retryCount + 1}/${retryConfig.maxRetries}] in ${retryDelayMs}ms due to status code ${statusCode}.`,
});
await delay(retryDelayMs);
}
return await withRetry(retryCount + 1, startTime);
}
};
const runWithTelemetry = async (adapter) => {
var _a;
try {
const result = await withRetry(0, requestStartTime);
const httpStatusCode = typeof (result === null || result === void 0 ? void 0 : result.httpStatusCode) === 'number'
? result.httpStatusCode
: 200;
try {
adapter.onRequestEnd(telemetryHandle, buildRequestTelemetryResult({
httpStatusCode,
durationMs: Date.now() - requestStartTime,
}));
}
catch (err) {
const message = err instanceof Error
? err.message
: 'Unknown telemetry adapter error';
log(env, {
level: 'ERROR',
message: `Telemetry adapter onRequestEnd failed: ${message}.`,
});
}
return result;
}
catch (err) {
const httpStatusCode = (_a = extractHttpStatusCode(err)) !== null && _a !== void 0 ? _a : 500;
const telemetryError = extractRequestTelemetryError(err);
try {
adapter.onRequestEnd(telemetryHandle, buildRequestTelemetryResult({
httpStatusCode,
durationMs: Date.now() - requestStartTime,
error: telemetryError,
}));
}
catch (telemetryErr) {
const message = telemetryErr instanceof Error
? telemetryErr.message
: 'Unknown telemetry adapter error';
log(env, {
level: 'ERROR',
message: `Telemetry adapter onRequestEnd failed: ${message}.`,
});
}
throw err;
}
};
const promise = telemetryAdapter !== undefined
? runWithTelemetry(telemetryAdapter)
: withRetry(0, requestStartTime);
return callbackifyPromise(promise);
}
_createHeaders(httpHeaders) {
const headers = new Headers();
Object.entries(httpHeaders).forEach(([key, value]) => {
headers.append(key, String(value));
});
return headers;
}
}