diff --git a/api/esplora/esploraAPiProvider.ts b/api/esplora/esploraAPiProvider.ts index 0dfd36ea..0632213f 100644 --- a/api/esplora/esploraAPiProvider.ts +++ b/api/esplora/esploraAPiProvider.ts @@ -1,5 +1,4 @@ import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; -import rateLimit from 'axios-rate-limit'; import axiosRetry from 'axios-retry'; import { XVERSE_BTC_BASE_URI_MAINNET, XVERSE_BTC_BASE_URI_SIGNET, XVERSE_BTC_BASE_URI_TESTNET } from '../../constant'; import { @@ -12,6 +11,7 @@ import { TransactionOutspend, UTXO, } from '../../types'; +import { AxiosRateLimit } from '../../utils/axiosRateLimit'; export interface EsploraApiProviderOptions { network: NetworkType; @@ -22,8 +22,12 @@ export interface EsploraApiProviderOptions { export class BitcoinEsploraApiProvider { bitcoinApi: AxiosInstance; + rateLimiter: AxiosRateLimit; + fallbackBitcoinApi?: AxiosInstance; + fallbackRateLimiter?: AxiosRateLimit; + _network: NetworkType; constructor(options: EsploraApiProviderOptions) { @@ -48,17 +52,30 @@ export class BitcoinEsploraApiProvider { const axiosConfig: AxiosRequestConfig = { baseURL }; this._network = network; - this.bitcoinApi = rateLimit(axios.create(axiosConfig), { + this.bitcoinApi = axios.create(axiosConfig); + + this.rateLimiter = new AxiosRateLimit(this.bitcoinApi, { maxRPS: 10, }); axiosRetry(this.bitcoinApi, { retries: 1, retryDelay: axiosRetry.exponentialDelay, + retryCondition: (error) => { + if (error?.response?.status === 429 || (error?.response?.status ?? 0) >= 500) { + return true; + } + return false; + }, }); if (fallbackUrl) { this.fallbackBitcoinApi = axios.create({ ...axiosConfig, baseURL: fallbackUrl }); + + this.fallbackRateLimiter = new AxiosRateLimit(this.fallbackBitcoinApi, { + maxRPS: 10, + }); + this.bitcoinApi.interceptors.response.use( // if the request succeeds, we do nothing. (response) => response, diff --git a/package-lock.json b/package-lock.json index 09a46bbf..3cd27ab2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,6 @@ "@zondax/ledger-stacks": "^1.0.4", "async-mutex": "^0.4.0", "axios": "1.7.7", - "axios-rate-limit": "1.4.0", "axios-retry": "4.5.0", "base64url": "^3.0.1", "bip32": "^4.0.0", @@ -2341,17 +2340,6 @@ "axios": ">= 0.17.0" } }, - "node_modules/axios-rate-limit": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/axios-rate-limit/-/axios-rate-limit-1.4.0.tgz", - "integrity": "sha512-uM5PbmSUdSle1I+59Av/wpLuNRobfatIR+FyylSoHcVHT20ohjflNnLMEHZQr7N2QVG/Wlt8jekIPhWwoKtpXQ==", - "dependencies": { - "axios": ">=0.18.0" - }, - "peerDependencies": { - "axios": "*" - } - }, "node_modules/axios-retry": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", diff --git a/package.json b/package.json index 20149b65..4c42f392 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "@zondax/ledger-stacks": "^1.0.4", "async-mutex": "^0.4.0", "axios": "1.7.7", - "axios-rate-limit": "1.4.0", "axios-retry": "4.5.0", "base64url": "^3.0.1", "bip32": "^4.0.0", diff --git a/utils/axiosRateLimit.ts b/utils/axiosRateLimit.ts new file mode 100644 index 00000000..102266f1 --- /dev/null +++ b/utils/axiosRateLimit.ts @@ -0,0 +1,98 @@ +/** + * inspired by axios-rate-limit with a few adjustments and bug fixes + * https://github.com/aishek/axios-rate-limit + */ + +import { AxiosInstance, InternalAxiosRequestConfig } from 'axios'; + +type RateLimitRequestHandler = { + resolve: () => boolean; +}; + +type RateLimitOptions = + | { + maxRPS: number; + } + | { + maxRequests: number; + perMilliseconds: number; + }; + +function throwIfCancellationRequested(config: InternalAxiosRequestConfig) { + if (config.cancelToken) { + config.cancelToken.throwIfRequested(); + } +} + +export class AxiosRateLimit { + private queue: RateLimitRequestHandler[]; + + private timeslotRequests: number; + + private perMilliseconds: number; + + private maxRequests: number; + + constructor(axios: AxiosInstance, options: RateLimitOptions) { + this.queue = []; + this.timeslotRequests = 0; + + if ('maxRPS' in options) { + this.perMilliseconds = 1000; + this.maxRequests = options.maxRPS; + } else { + this.perMilliseconds = options.perMilliseconds; + this.maxRequests = options.maxRequests; + } + + function handleError(error: unknown) { + return Promise.reject(error); + } + + axios.interceptors.request.use(this.handleRequest, handleError); + } + + private handleRequest = (request: InternalAxiosRequestConfig) => { + return new Promise>((resolve, reject) => { + this.push({ + resolve: function () { + try { + throwIfCancellationRequested(request); + } catch (error) { + reject(error); + return false; + } + resolve(request); + return true; + }, + }); + }); + }; + + private push = (requestHandler: RateLimitRequestHandler) => { + this.queue.push(requestHandler); + this.shift(); + }; + + private onRequestTimerMet = () => { + this.timeslotRequests--; + this.shift(); + }; + + private shift = () => { + if (this.timeslotRequests >= this.maxRequests) return; + + const queued = this.queue.shift(); + if (!queued) return; + + const resolved = queued.resolve(); + + if (!resolved) { + this.shift(); // rejected request --> shift another request + return; + } + + this.timeslotRequests++; + setTimeout(this.onRequestTimerMet, this.perMilliseconds); + }; +}