diff --git a/packages/asset-swapper/src/constants.ts b/packages/asset-swapper/src/constants.ts index d085ff64a6..049f890701 100644 --- a/packages/asset-swapper/src/constants.ts +++ b/packages/asset-swapper/src/constants.ts @@ -20,6 +20,8 @@ const NULL_ERC20_ASSET_DATA = '0xf47261b0000000000000000000000000000000000000000 const NULL_ADDRESS = '0x0000000000000000000000000000000000000000'; const MAINNET_CHAIN_ID = 1; const ONE_SECOND_MS = 1000; +const ONE_MINUTE_SECS = 60; +const ONE_MINUTE_MS = ONE_SECOND_MS * ONE_MINUTE_SECS; const DEFAULT_PER_PAGE = 1000; const ZERO_AMOUNT = new BigNumber(0); @@ -97,6 +99,7 @@ export const constants = { ETHER_TOKEN_DECIMALS: 18, ONE_AMOUNT: new BigNumber(1), ONE_SECOND_MS, + ONE_MINUTE_MS, DEFAULT_SWAP_QUOTER_OPTS, DEFAULT_FORWARDER_SWAP_QUOTE_GET_OPTS, DEFAULT_FORWARDER_SWAP_QUOTE_EXECUTE_OPTS, diff --git a/packages/asset-swapper/src/utils/quote_requestor.ts b/packages/asset-swapper/src/utils/quote_requestor.ts index 86519daa9a..cd34d0d07a 100644 --- a/packages/asset-swapper/src/utils/quote_requestor.ts +++ b/packages/asset-swapper/src/utils/quote_requestor.ts @@ -11,6 +11,7 @@ import { constants } from '../constants'; import { MarketOperation, RfqtMakerAssetOfferings, RfqtRequestOpts } from '../types'; import { ONE_SECOND_MS } from './market_operation_utils/constants'; +import { RfqMakerBlacklist } from './rfq_maker_blacklist'; // tslint:disable-next-line: custom-no-magic-numbers const KEEP_ALIVE_TTL = 5 * 60 * ONE_SECOND_MS; @@ -20,6 +21,10 @@ export const quoteRequestorHttpClient: AxiosInstance = Axios.create({ httpsAgent: new HttpsAgent({ keepAlive: true, timeout: KEEP_ALIVE_TTL }), }); +const MAKER_TIMEOUT_STREAK_LENGTH = 10; +const MAKER_TIMEOUT_BLACKLIST_DURATION_MINUTES = 10; +const rfqMakerBlacklist = new RfqMakerBlacklist(MAKER_TIMEOUT_STREAK_LENGTH, MAKER_TIMEOUT_BLACKLIST_DURATION_MINUTES); + /** * Request quotes from RFQ-T providers */ @@ -334,7 +339,10 @@ export class QuoteRequestor { const result: Array<{ response: ResponseT; makerUri: string }> = []; await Promise.all( Object.keys(this._rfqtAssetOfferings).map(async url => { - if (this._makerSupportsPair(url, makerAssetData, takerAssetData)) { + if ( + this._makerSupportsPair(url, makerAssetData, takerAssetData) && + !rfqMakerBlacklist.isMakerBlacklisted(url) + ) { const requestParamsWithBigNumbers = { takerAddress: options.takerAddress, ...inferQueryParams(marketOperation, makerAssetData, takerAssetData, assetFillAmount), @@ -354,6 +362,10 @@ export class QuoteRequestor { const partialLogEntry = { url, quoteType, requestParams }; const timeBeforeAwait = Date.now(); + const maxResponseTimeMs = + options.makerEndpointMaxResponseTimeMs === undefined + ? constants.DEFAULT_RFQT_REQUEST_OPTS.makerEndpointMaxResponseTimeMs! + : options.makerEndpointMaxResponseTimeMs; try { const quotePath = (() => { switch (quoteType) { @@ -368,8 +380,9 @@ export class QuoteRequestor { const response = await quoteRequestorHttpClient.get(`${url}/${quotePath}`, { headers: { '0x-api-key': options.apiKey }, params: requestParams, - timeout: options.makerEndpointMaxResponseTimeMs, + timeout: maxResponseTimeMs, }); + const latencyMs = Date.now() - timeBeforeAwait; this._infoLogger({ rfqtMakerInteraction: { ...partialLogEntry, @@ -378,12 +391,14 @@ export class QuoteRequestor { apiKey: options.apiKey, takerAddress: requestParams.takerAddress, statusCode: response.status, - latencyMs: Date.now() - timeBeforeAwait, + latencyMs, }, }, }); + rfqMakerBlacklist.logTimeoutOrLackThereof(url, latencyMs > maxResponseTimeMs); result.push({ response: response.data, makerUri: url }); } catch (err) { + const latencyMs = Date.now() - timeBeforeAwait; this._infoLogger({ rfqtMakerInteraction: { ...partialLogEntry, @@ -392,10 +407,11 @@ export class QuoteRequestor { apiKey: options.apiKey, takerAddress: requestParams.takerAddress, statusCode: err.response ? err.response.status : undefined, - latencyMs: Date.now() - timeBeforeAwait, + latencyMs, }, }, }); + rfqMakerBlacklist.logTimeoutOrLackThereof(url, latencyMs > maxResponseTimeMs); this._warningLogger( convertIfAxiosError(err), `Failed to get RFQ-T ${quoteType} quote from market maker endpoint ${url} for API key ${ diff --git a/packages/asset-swapper/src/utils/rfq_maker_blacklist.ts b/packages/asset-swapper/src/utils/rfq_maker_blacklist.ts new file mode 100644 index 0000000000..b261b116e0 --- /dev/null +++ b/packages/asset-swapper/src/utils/rfq_maker_blacklist.ts @@ -0,0 +1,33 @@ +/** + * Tracks a maker's history of timely responses, and manages whether a given + * maker should be avoided for being too latent. + */ + +import { constants } from '../constants'; + +export class RfqMakerBlacklist { + private readonly _makerTimeoutStreakLength: { [makerUrl: string]: number } = {}; + private readonly _makerBlacklistedUntilDate: { [makerUrl: string]: number } = {}; + constructor(private readonly _blacklistDurationMinutes: number, private readonly _timeoutStreakThreshold: number) {} + public logTimeoutOrLackThereof(makerUrl: string, didTimeout: boolean): void { + if (!this._makerTimeoutStreakLength.hasOwnProperty(makerUrl)) { + this._makerTimeoutStreakLength[makerUrl] = 0; + } + if (didTimeout) { + this._makerTimeoutStreakLength[makerUrl] += 1; + if (this._makerTimeoutStreakLength[makerUrl] === this._timeoutStreakThreshold) { + this._makerBlacklistedUntilDate[makerUrl] = + Date.now() + this._blacklistDurationMinutes * constants.ONE_MINUTE_MS; + } + } else { + this._makerTimeoutStreakLength[makerUrl] = 0; + } + } + public isMakerBlacklisted(makerUrl: string): boolean { + const now = Date.now(); + if (now > this._makerBlacklistedUntilDate[makerUrl]) { + delete this._makerBlacklistedUntilDate[makerUrl]; + } + return this._makerBlacklistedUntilDate[makerUrl] > now; + } +} diff --git a/packages/asset-swapper/test/rfq_maker_blacklist_test.ts b/packages/asset-swapper/test/rfq_maker_blacklist_test.ts new file mode 100644 index 0000000000..50a1f5d01d --- /dev/null +++ b/packages/asset-swapper/test/rfq_maker_blacklist_test.ts @@ -0,0 +1,40 @@ +import * as chai from 'chai'; +import 'mocha'; + +import { constants } from '../src/constants'; +import { RfqMakerBlacklist } from '../src/utils/rfq_maker_blacklist'; + +import { chaiSetup } from './utils/chai_setup'; + +chaiSetup.configure(); +const expect = chai.expect; + +describe('RfqMakerBlacklist', () => { + it('does blacklist', async () => { + const blacklistDurationMinutes = 1; + const timeoutStreakThreshold = 3; + const blacklist = new RfqMakerBlacklist(blacklistDurationMinutes, timeoutStreakThreshold); + blacklist.logTimeoutOrLackThereof('makerA', true); + blacklist.logTimeoutOrLackThereof('makerA', true); + expect(blacklist.isMakerBlacklisted('makerA')).to.be.false(); + blacklist.logTimeoutOrLackThereof('makerA', true); + const sleepTimeMs = 10; + await new Promise(r => { + setTimeout(r, sleepTimeMs); + }); + expect(blacklist.isMakerBlacklisted('makerA')).to.be.true(); + }); + it('does unblacklist', async () => { + const blacklistDurationMinutes = 0.1; + const timeoutStreakThreshold = 3; + const blacklist = new RfqMakerBlacklist(blacklistDurationMinutes, timeoutStreakThreshold); + blacklist.logTimeoutOrLackThereof('makerA', true); + blacklist.logTimeoutOrLackThereof('makerA', true); + blacklist.logTimeoutOrLackThereof('makerA', true); + expect(blacklist.isMakerBlacklisted('makerA')).to.be.true(); + await new Promise(r => { + setTimeout(r, blacklistDurationMinutes * constants.ONE_MINUTE_MS); + }); + expect(blacklist.isMakerBlacklisted('makerA')).to.be.false(); + }); +});