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",
"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;
responseData: any;
responseCode: number;
callback?: (config: any) => Promise<any>;
}
/**

View File

@ -1,7 +1,7 @@
import { Web3Wrapper } from '@0x/dev-utils';
import { TakerRequestQueryParams, V4RFQFirmQuote, V4RFQIndicativeQuote } from '@0x/quote-server';
import { BigNumber } from '@0x/utils';
import { AxiosInstance } from 'axios';
import { AxiosInstance, CancelToken } from 'axios';
import { constants } from '../constants';
import {
@ -122,6 +122,7 @@ export async function returnQuoteFromAltMMAsync<ResponseT>(
altRfqtAssetOfferings: AltRfqtMakerAssetOfferings,
takerRequestQueryParams: TakerRequestQueryParams,
axiosInstance: AxiosInstance,
cancelToken: CancelToken,
): Promise<{ data: ResponseT; status: number }> {
const altPair = getAltMarketInfo(
altRfqtAssetOfferings[url],
@ -214,6 +215,7 @@ export async function returnQuoteFromAltMMAsync<ResponseT>(
const response = await axiosInstance.post(`${url}/quotes`, data, {
headers: { Authorization: `Bearer ${apiKey}` },
timeout: maxResponseTimeMs,
cancelToken,
});
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 { TakerRequestQueryParams, V4RFQFirmQuote, V4RFQIndicativeQuote, V4SignedRfqOrder } from '@0x/quote-server';
import { BigNumber, NULL_ADDRESS } from '@0x/utils';
import { AxiosInstance } from 'axios';
import axios, { AxiosInstance } from 'axios';
import { constants } from '../constants';
import {
@ -392,6 +392,17 @@ export class QuoteRequestor {
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 => {
// filter out requests to skip
const isBlacklisted = rfqMakerBlacklist.isMakerBlacklisted(typedMakerUrl.url);
@ -404,16 +415,13 @@ export class QuoteRequestor {
} else {
// make request to MM
const timeBeforeAwait = Date.now();
const maxResponseTimeMs =
options.makerEndpointMaxResponseTimeMs === undefined
? constants.DEFAULT_RFQT_REQUEST_OPTS.makerEndpointMaxResponseTimeMs!
: options.makerEndpointMaxResponseTimeMs;
try {
if (typedMakerUrl.pairType === RfqPairType.Standard) {
const response = await this._quoteRequestorHttpClient.get(`${typedMakerUrl.url}/${quotePath}`, {
headers: { '0x-api-key': options.apiKey },
params: requestParams,
timeout: maxResponseTimeMs,
timeout: timeoutMs,
cancelToken: cancelTokenSource.token,
});
const latencyMs = Date.now() - timeBeforeAwait;
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 };
} else {
if (this._altRfqCreds === undefined) {
@ -443,10 +451,11 @@ export class QuoteRequestor {
quoteType === 'firm' ? AltQuoteModel.Firm : AltQuoteModel.Indicative,
makerToken,
takerToken,
maxResponseTimeMs,
timeoutMs,
options.altRfqtAssetOfferings || {},
requestParams,
this._quoteRequestorHttpClient,
cancelTokenSource.token,
);
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 };
}
} catch (err) {
@ -482,7 +491,7 @@ export class QuoteRequestor {
},
},
});
rfqMakerBlacklist.logTimeoutOrLackThereof(typedMakerUrl.url, latencyMs >= maxResponseTimeMs);
rfqMakerBlacklist.logTimeoutOrLackThereof(typedMakerUrl.url, latencyMs >= timeoutMs);
this._warningLogger(
convertIfAxiosError(err),
`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);
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 { StatusCodes } from '@0x/types';
import { BigNumber, logUtils } from '@0x/utils';
import Axios, { AxiosInstance } from 'axios';
import Axios from 'axios';
import * as chai from 'chai';
import { Agent as HttpAgent } from 'http';
import { Agent as HttpsAgent } from 'https';
@ -366,6 +366,93 @@ describe('QuoteRequestor', async () => {
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 () => {
const takerAddress = '0xd209925defc99488e3afff1174e48b4fa628302a';
const apiKey = 'my-ko0l-api-key';

View File

@ -49,10 +49,16 @@ export const testHelpers = {
for (const mockedResponse of standardMockedResponses) {
const { endpoint, requestApiKey, requestParams, responseData, responseCode } = mockedResponse;
const requestHeaders = { Accept: 'application/json, text/plain, */*', '0x-api-key': requestApiKey };
if (mockedResponse.callback !== undefined) {
mockedAxios
.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
for (const mockedResponse of altMockedResponses) {
const { endpoint, mmApiKey, requestData, responseData, responseCode } = mockedResponse;