Add a cancel token to manually enforce a timeout in Quote Requestor (#176)

* Add a cancel token to manually enforce a timeout in Quote Requestor

* Start setTimeout before making requests, add an extra buffer

* Run prettier

* Add comment to changelog
This commit is contained in:
phil-ociraptor 2021-03-22 17:08:51 -05:00 committed by GitHub
parent 5c683cbc0f
commit bbaa90bd9a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 125 additions and 15 deletions

View File

@ -5,6 +5,10 @@
{ {
"note": "Use SOURCE_FLAGS.rfqOrder in comparisonPrice", "note": "Use SOURCE_FLAGS.rfqOrder in comparisonPrice",
"pr": 177 "pr": 177
},
{
"note": "Add a cancel token to ensure timeouts are respected",
"pr": 176
} }
] ]
}, },

View File

@ -368,6 +368,7 @@ export interface MockedRfqtQuoteResponse {
requestParams: TakerRequestQueryParams; requestParams: TakerRequestQueryParams;
responseData: any; responseData: any;
responseCode: number; responseCode: number;
callback?: (config: any) => Promise<any>;
} }
/** /**

View File

@ -1,7 +1,7 @@
import { Web3Wrapper } from '@0x/dev-utils'; import { Web3Wrapper } from '@0x/dev-utils';
import { TakerRequestQueryParams, V4RFQFirmQuote, V4RFQIndicativeQuote } from '@0x/quote-server'; import { TakerRequestQueryParams, V4RFQFirmQuote, V4RFQIndicativeQuote } from '@0x/quote-server';
import { BigNumber } from '@0x/utils'; import { BigNumber } from '@0x/utils';
import { AxiosInstance } from 'axios'; import { AxiosInstance, CancelToken } from 'axios';
import { constants } from '../constants'; import { constants } from '../constants';
import { import {
@ -122,6 +122,7 @@ export async function returnQuoteFromAltMMAsync<ResponseT>(
altRfqtAssetOfferings: AltRfqtMakerAssetOfferings, altRfqtAssetOfferings: AltRfqtMakerAssetOfferings,
takerRequestQueryParams: TakerRequestQueryParams, takerRequestQueryParams: TakerRequestQueryParams,
axiosInstance: AxiosInstance, axiosInstance: AxiosInstance,
cancelToken: CancelToken,
): Promise<{ data: ResponseT; status: number }> { ): Promise<{ data: ResponseT; status: number }> {
const altPair = getAltMarketInfo( const altPair = getAltMarketInfo(
altRfqtAssetOfferings[url], altRfqtAssetOfferings[url],
@ -214,6 +215,7 @@ export async function returnQuoteFromAltMMAsync<ResponseT>(
const response = await axiosInstance.post(`${url}/quotes`, data, { const response = await axiosInstance.post(`${url}/quotes`, data, {
headers: { Authorization: `Bearer ${apiKey}` }, headers: { Authorization: `Bearer ${apiKey}` },
timeout: maxResponseTimeMs, timeout: maxResponseTimeMs,
cancelToken,
}); });
if (response.data.status === 'rejected') { if (response.data.status === 'rejected') {

View File

@ -2,7 +2,7 @@ import { schemas, SchemaValidator } from '@0x/json-schemas';
import { FillQuoteTransformerOrderType, Signature } from '@0x/protocol-utils'; import { FillQuoteTransformerOrderType, Signature } from '@0x/protocol-utils';
import { TakerRequestQueryParams, V4RFQFirmQuote, V4RFQIndicativeQuote, V4SignedRfqOrder } from '@0x/quote-server'; import { TakerRequestQueryParams, V4RFQFirmQuote, V4RFQIndicativeQuote, V4SignedRfqOrder } from '@0x/quote-server';
import { BigNumber, NULL_ADDRESS } from '@0x/utils'; import { BigNumber, NULL_ADDRESS } from '@0x/utils';
import { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
import { constants } from '../constants'; import { constants } from '../constants';
import { import {
@ -392,6 +392,17 @@ export class QuoteRequestor {
const typedMakerUrls = standardUrls.concat(altUrls); const typedMakerUrls = standardUrls.concat(altUrls);
const timeoutMs =
options.makerEndpointMaxResponseTimeMs ||
constants.DEFAULT_RFQT_REQUEST_OPTS.makerEndpointMaxResponseTimeMs!;
const bufferMs = 20;
// Set Timeout on CancelToken
const cancelTokenSource = axios.CancelToken.source();
setTimeout(() => {
cancelTokenSource.cancel('timeout via cancel token');
}, timeoutMs + bufferMs);
const quotePromises = typedMakerUrls.map(async typedMakerUrl => { const quotePromises = typedMakerUrls.map(async typedMakerUrl => {
// filter out requests to skip // filter out requests to skip
const isBlacklisted = rfqMakerBlacklist.isMakerBlacklisted(typedMakerUrl.url); const isBlacklisted = rfqMakerBlacklist.isMakerBlacklisted(typedMakerUrl.url);
@ -404,16 +415,13 @@ export class QuoteRequestor {
} else { } else {
// make request to MM // make request to MM
const timeBeforeAwait = Date.now(); const timeBeforeAwait = Date.now();
const maxResponseTimeMs =
options.makerEndpointMaxResponseTimeMs === undefined
? constants.DEFAULT_RFQT_REQUEST_OPTS.makerEndpointMaxResponseTimeMs!
: options.makerEndpointMaxResponseTimeMs;
try { try {
if (typedMakerUrl.pairType === RfqPairType.Standard) { if (typedMakerUrl.pairType === RfqPairType.Standard) {
const response = await this._quoteRequestorHttpClient.get(`${typedMakerUrl.url}/${quotePath}`, { const response = await this._quoteRequestorHttpClient.get(`${typedMakerUrl.url}/${quotePath}`, {
headers: { '0x-api-key': options.apiKey }, headers: { '0x-api-key': options.apiKey },
params: requestParams, params: requestParams,
timeout: maxResponseTimeMs, timeout: timeoutMs,
cancelToken: cancelTokenSource.token,
}); });
const latencyMs = Date.now() - timeBeforeAwait; const latencyMs = Date.now() - timeBeforeAwait;
this._infoLogger({ this._infoLogger({
@ -429,7 +437,7 @@ export class QuoteRequestor {
}, },
}, },
}); });
rfqMakerBlacklist.logTimeoutOrLackThereof(typedMakerUrl.url, latencyMs >= maxResponseTimeMs); rfqMakerBlacklist.logTimeoutOrLackThereof(typedMakerUrl.url, latencyMs >= timeoutMs);
return { response: response.data, makerUri: typedMakerUrl.url }; return { response: response.data, makerUri: typedMakerUrl.url };
} else { } else {
if (this._altRfqCreds === undefined) { if (this._altRfqCreds === undefined) {
@ -443,10 +451,11 @@ export class QuoteRequestor {
quoteType === 'firm' ? AltQuoteModel.Firm : AltQuoteModel.Indicative, quoteType === 'firm' ? AltQuoteModel.Firm : AltQuoteModel.Indicative,
makerToken, makerToken,
takerToken, takerToken,
maxResponseTimeMs, timeoutMs,
options.altRfqtAssetOfferings || {}, options.altRfqtAssetOfferings || {},
requestParams, requestParams,
this._quoteRequestorHttpClient, this._quoteRequestorHttpClient,
cancelTokenSource.token,
); );
const latencyMs = Date.now() - timeBeforeAwait; const latencyMs = Date.now() - timeBeforeAwait;
@ -463,7 +472,7 @@ export class QuoteRequestor {
}, },
}, },
}); });
rfqMakerBlacklist.logTimeoutOrLackThereof(typedMakerUrl.url, latencyMs >= maxResponseTimeMs); rfqMakerBlacklist.logTimeoutOrLackThereof(typedMakerUrl.url, latencyMs >= timeoutMs);
return { response: quote.data, makerUri: typedMakerUrl.url }; return { response: quote.data, makerUri: typedMakerUrl.url };
} }
} catch (err) { } catch (err) {
@ -482,7 +491,7 @@ export class QuoteRequestor {
}, },
}, },
}); });
rfqMakerBlacklist.logTimeoutOrLackThereof(typedMakerUrl.url, latencyMs >= maxResponseTimeMs); rfqMakerBlacklist.logTimeoutOrLackThereof(typedMakerUrl.url, latencyMs >= timeoutMs);
this._warningLogger( this._warningLogger(
convertIfAxiosError(err), convertIfAxiosError(err),
`Failed to get RFQ-T ${quoteType} quote from market maker endpoint ${ `Failed to get RFQ-T ${quoteType} quote from market maker endpoint ${
@ -495,6 +504,7 @@ export class QuoteRequestor {
} }
} }
}); });
const results = (await Promise.all(quotePromises)).filter(x => x !== undefined); const results = (await Promise.all(quotePromises)).filter(x => x !== undefined);
return results as Array<RfqQuote<ResponseT>>; return results as Array<RfqQuote<ResponseT>>;
} }

View File

@ -3,7 +3,7 @@ import { FillQuoteTransformerOrderType, SignatureType } from '@0x/protocol-utils
import { TakerRequestQueryParams, V4RFQIndicativeQuote } from '@0x/quote-server'; import { TakerRequestQueryParams, V4RFQIndicativeQuote } from '@0x/quote-server';
import { StatusCodes } from '@0x/types'; import { StatusCodes } from '@0x/types';
import { BigNumber, logUtils } from '@0x/utils'; import { BigNumber, logUtils } from '@0x/utils';
import Axios, { AxiosInstance } from 'axios'; import Axios from 'axios';
import * as chai from 'chai'; import * as chai from 'chai';
import { Agent as HttpAgent } from 'http'; import { Agent as HttpAgent } from 'http';
import { Agent as HttpsAgent } from 'https'; import { Agent as HttpsAgent } from 'https';
@ -366,6 +366,93 @@ describe('QuoteRequestor', async () => {
quoteRequestorHttpClient, quoteRequestorHttpClient,
); );
}); });
it('should only return RFQT requests that meet the timeout', async () => {
const takerAddress = '0xd209925defc99488e3afff1174e48b4fa628302a';
const apiKey = 'my-ko0l-api-key';
const maxTimeoutMs = 10;
// tslint:disable-next-line:custom-no-magic-numbers
const exceedTimeoutMs = maxTimeoutMs + 50;
// Set up RFQT responses
// tslint:disable-next-line:array-type
const mockedRequests: MockedRfqtQuoteResponse[] = [];
const expectedParams: TakerRequestQueryParams = {
sellTokenAddress: takerToken,
buyTokenAddress: makerToken,
sellAmountBaseUnits: '10000',
comparisonPrice: undefined,
takerAddress,
txOrigin: takerAddress,
protocolVersion: '4',
};
const mockedDefaults = {
requestApiKey: apiKey,
requestParams: expectedParams,
responseCode: StatusCodes.Success,
};
// Successful response
const successfulQuote1 = {
makerToken,
takerToken,
makerAmount: new BigNumber(expectedParams.sellAmountBaseUnits),
takerAmount: new BigNumber(expectedParams.sellAmountBaseUnits),
expiry: makeThreeMinuteExpiry(),
};
// One good request
mockedRequests.push({
...mockedDefaults,
endpoint: 'https://1337.0.0.1',
responseData: successfulQuote1,
});
// One request that will timeout
mockedRequests.push({
...mockedDefaults,
endpoint: 'https://420.0.0.1',
responseData: successfulQuote1,
callback: async () => {
// tslint:disable-next-line:no-inferred-empty-object-type
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve([StatusCodes.Success, successfulQuote1]);
}, exceedTimeoutMs);
});
},
});
return testHelpers.withMockedRfqtQuotes(
mockedRequests,
[],
RfqtQuoteEndpoint.Indicative,
async () => {
const qr = new QuoteRequestor(
{
'https://1337.0.0.1': [[makerToken, takerToken]],
'https://420.0.0.1': [[makerToken, takerToken]],
},
quoteRequestorHttpClient,
);
const resp = await qr.requestRfqtIndicativeQuotesAsync(
makerToken,
takerToken,
new BigNumber(10000),
MarketOperation.Sell,
undefined,
{
apiKey,
takerAddress,
txOrigin: takerAddress,
intentOnFilling: true,
makerEndpointMaxResponseTimeMs: maxTimeoutMs,
},
);
expect(resp.sort()).to.eql([successfulQuote1].sort()); // notice only one result, despite two requests made
},
quoteRequestorHttpClient,
);
});
it('should return successful RFQT indicative quote requests (Buy)', async () => { it('should return successful RFQT indicative quote requests (Buy)', async () => {
const takerAddress = '0xd209925defc99488e3afff1174e48b4fa628302a'; const takerAddress = '0xd209925defc99488e3afff1174e48b4fa628302a';
const apiKey = 'my-ko0l-api-key'; const apiKey = 'my-ko0l-api-key';

View File

@ -49,9 +49,15 @@ export const testHelpers = {
for (const mockedResponse of standardMockedResponses) { for (const mockedResponse of standardMockedResponses) {
const { endpoint, requestApiKey, requestParams, responseData, responseCode } = mockedResponse; const { endpoint, requestApiKey, requestParams, responseData, responseCode } = mockedResponse;
const requestHeaders = { Accept: 'application/json, text/plain, */*', '0x-api-key': requestApiKey }; const requestHeaders = { Accept: 'application/json, text/plain, */*', '0x-api-key': requestApiKey };
mockedAxios if (mockedResponse.callback !== undefined) {
.onGet(`${endpoint}/${quoteType}`, { params: requestParams }, requestHeaders) mockedAxios
.replyOnce(responseCode, responseData); .onGet(`${endpoint}/${quoteType}`, { params: requestParams }, requestHeaders)
.reply(mockedResponse.callback);
} else {
mockedAxios
.onGet(`${endpoint}/${quoteType}`, { params: requestParams }, requestHeaders)
.replyOnce(responseCode, responseData);
}
} }
// Mock out Alt RFQT responses // Mock out Alt RFQT responses
for (const mockedResponse of altMockedResponses) { for (const mockedResponse of altMockedResponses) {