UNPKG

chargebee

Version:

A library for integrating with Chargebee.

282 lines (281 loc) 13.4 kB
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; } }