import Diagnostics from '../Diagnostics'; import ErrorBoundary from '../ErrorBoundary'; import { StatsigLocalModeNetworkError, StatsigSDKKeyMismatchError, StatsigTooManyRequestsError, } from '../Errors'; import { SDKConfigs } from '../SDKConfigs'; import { ExplicitStatsigOptions, LoggerInterface, NetworkOverrideFunc, RetryBackoffFunc, } from '../StatsigOptions'; import { AbortSignalLike } from './AbortSignalLike'; import { getSDKType, getSDKVersion } from './core'; import Dispatcher from './Dispatcher'; import { getEncodedBody } from './getEncodedBody'; import { djb2Hash } from './Hashing'; import safeFetch from './safeFetch'; import { StatsigContext } from './StatsigContext'; const retryStatusCodes = [0, 408, 500, 502, 503, 504, 522, 524, 599]; export const STATSIG_API = 'https://statsigapi.net/v1'; export const STATSIG_CDN = 'https://api.statsigcdn.com/v1'; type RequestOptions = Partial<{ retries: number; retryURL: string; backoff: number | RetryBackoffFunc; isRetrying: boolean; signal: AbortSignalLike; compress?: boolean; additionalHeaders?: Record; }>; export default class StatsigFetcher { private apiForDownloadConfigSpecs: string; private apiForGetIdLists: string; private fallbackToStatsigAPI: boolean; private sessionID: string; private leakyBucket: Record; private pendingTimers: NodeJS.Timer[]; private dispatcher: Dispatcher; private localMode: boolean; private sdkKey: string; private errorBoundry: ErrorBoundary; private networkOverrideFunc: NetworkOverrideFunc | null; private outputLogger: LoggerInterface | null = null; public constructor( secretKey: string, options: ExplicitStatsigOptions, errorBoundry: ErrorBoundary, sessionID: string, ) { this.apiForDownloadConfigSpecs = options.apiForDownloadConfigSpecs; this.apiForGetIdLists = options.apiForGetIdLists; this.fallbackToStatsigAPI = options.fallbackToStatsigAPI; this.sessionID = sessionID; this.leakyBucket = {}; this.pendingTimers = []; this.dispatcher = new Dispatcher(200); this.localMode = options.localMode; this.sdkKey = secretKey; this.errorBoundry = errorBoundry; this.networkOverrideFunc = options.networkOverrideFunc; this.outputLogger = options.logger ?? null; } public validateSDKKeyUsed(hashedSDKKeyUsed: string): boolean { const matched = hashedSDKKeyUsed === djb2Hash(this.sdkKey); if (!matched) { this.errorBoundry.logError( new StatsigSDKKeyMismatchError(), StatsigContext.new({ caller: 'validateSDKKeyUsed' }), ); } return matched; } public async downloadConfigSpecs(sinceTime: number): Promise { const path = '/download_config_specs' + `/${this.sdkKey}.json` + (sinceTime === 0 ? '' : `?sinceTime=${sinceTime}`); const url = this.apiForDownloadConfigSpecs + path; let options: RequestOptions | undefined; if (this.fallbackToStatsigAPI) { try { const res = await this.get(url, options); if (res.ok) { return res; } this.outputLogger?.warn( `Failed to download config specs from ${this.apiForDownloadConfigSpecs}, status code: ${res.status}. Falling back to Statsig API.`, ); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); this.outputLogger?.warn( `Error downloading config specs from: ${message}. Falling back to Statsig API.`, ); } // Fallback to Statsig API return await this.get(STATSIG_CDN + path); } return await this.get(url, options); } public async getIDLists(): Promise { const path = '/get_id_lists'; const url = this.apiForGetIdLists + path; let options: RequestOptions | undefined; if (this.fallbackToStatsigAPI) { try { const res = await this.post(url, {}, options); if (res.ok) { return res; } this.outputLogger?.warn( `Failed to download ID lists from ${this.apiForGetIdLists}, status code: ${res.status}. Falling back to Statsig API.`, ); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); this.outputLogger?.warn(`Error getting id lists from: ${message}`); } // Fallback to Statsig API return await this.post(STATSIG_API + path, {}); } return await this.post(url, {}, options); } public dispatch( url: string, body: Record, timeout: number, ): Promise { return this.dispatcher.enqueue(this.post(url, body), timeout); } public async post( url: string, body: Record, options?: RequestOptions, ): Promise { return await this.request('POST', url, body, options); } public async get(url: string, options?: RequestOptions): Promise { return await this.request('GET', url, undefined, options); } public async request( method: 'POST' | 'GET', url: string, body?: Record, options?: RequestOptions, ): Promise { const { retryURL = url, retries = 0, backoff = 1000, isRetrying = false, signal, compress = false, } = options ?? {}; const markDiagnostic = this.getDiagnosticFromURL(url); if (this.localMode) { return Promise.reject(new StatsigLocalModeNetworkError()); } const counter = this.leakyBucket[url]; if (counter != null && counter >= 1000) { return Promise.reject( new StatsigTooManyRequestsError( `Request to ${url} failed because you are making the same request too frequently (${counter}).`, ), ); } if (counter == null) { this.leakyBucket[url] = 1; } else { this.leakyBucket[url] = counter + 1; } const applyBackoffMultiplier = (backoff: number) => isRetrying ? backoff * 10 : backoff; const backoffAdjusted = typeof backoff === 'number' ? applyBackoffMultiplier(backoff) : backoff(retries); const headers = { ...options?.additionalHeaders, 'Content-type': 'application/json; charset=UTF-8', 'STATSIG-API-KEY': this.sdkKey, 'STATSIG-CLIENT-TIME': `${Date.now()}`, 'STATSIG-SERVER-SESSION-ID': this.sessionID, 'STATSIG-SDK-TYPE': getSDKType(), 'STATSIG-SDK-VERSION': getSDKVersion(), } as Record; const { contents, contentEncoding } = await getEncodedBody( body, compress ? 'gzip' : 'none', this.errorBoundry, ); if (contentEncoding) { headers['Content-Encoding'] = contentEncoding; } if (!isRetrying) { markDiagnostic?.start({}); } let res: Response | undefined; let error: unknown; const fetcher = this.networkOverrideFunc ?? safeFetch; return fetcher(url, { method: method, body: contents, headers, signal: signal, }) .then((localRes) => { res = localRes; if ((!res.ok || retryStatusCodes.includes(res.status)) && retries > 0) { return this._retry( method, retryURL, body, retries - 1, backoffAdjusted, ); } else if (!res.ok) { return Promise.reject( new Error( 'Request to ' + url + ' failed with status ' + res.status, ), ); } return Promise.resolve(res); }) .catch((e) => { error = e; if (retries > 0) { return this._retry(method, url, body, retries - 1, backoffAdjusted); } return Promise.reject(error); }) .finally(() => { markDiagnostic?.end({ statusCode: res?.status, success: res?.ok === true, sdkRegion: res?.headers?.get('x-statsig-region'), error: Diagnostics.formatNetworkError(error), }); this.leakyBucket[url] = Math.max(this.leakyBucket[url] - 1, 0); if (this.leakyBucket[url] <= 0) { delete this.leakyBucket[url]; } }); } public shutdown(): void { if (this.pendingTimers != null) { this.pendingTimers.forEach((timer) => { if (timer != null) { clearTimeout(timer); } }); } if (this.dispatcher != null) { this.dispatcher.shutdown(); } } private _retry( method: 'POST' | 'GET', url: string, body: Record | undefined, retries: number, backoff: number, ): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.leakyBucket[url] = Math.max(this.leakyBucket[url] - 1, 0); if (this.leakyBucket[url] <= 0) { delete this.leakyBucket[url]; } this.pendingTimers = this.pendingTimers.filter((t) => t !== timer); this.request(method, url, body, { retries, backoff, isRetrying: true }) .then(resolve) .catch(reject); }, backoff); if (timer.unref) { timer.unref(); } this.pendingTimers.push(timer); }); } private getDiagnosticFromURL( url: string, ): | typeof Diagnostics.mark.downloadConfigSpecs.networkRequest | typeof Diagnostics.mark.getIDListSources.networkRequest | null { if (url.includes('/download_config_specs')) { return Diagnostics.mark.downloadConfigSpecs.networkRequest; } if (url.includes('/get_id_lists')) { return Diagnostics.mark.getIDListSources.networkRequest; } return null; } }