diff --git a/.changeset/cuddly-roses-yawn.md b/.changeset/cuddly-roses-yawn.md new file mode 100644 index 0000000000..5460c449f3 --- /dev/null +++ b/.changeset/cuddly-roses-yawn.md @@ -0,0 +1,10 @@ +--- +'@api3/airnode-utilities': minor +'@api3/airnode-validator': minor +'@api3/airnode-deployer': minor +'@api3/airnode-adapter': minor +'@api3/airnode-admin': minor +'@api3/airnode-node': minor +--- + +update promise-utils to v0.4.0 diff --git a/package.json b/package.json index 49aab5b87e..ff3f56411d 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ }, "dependencies": {}, "devDependencies": { - "@api3/promise-utils": "^0.3.0", + "@api3/promise-utils": "^0.4.0", "@changesets/changelog-github": "^0.4.8", "@changesets/cli": "^2.26.0", "@octokit/core": "^4.2.0", diff --git a/packages/airnode-adapter/package.json b/packages/airnode-adapter/package.json index db97a1b0a6..8b3a9cf9b8 100644 --- a/packages/airnode-adapter/package.json +++ b/packages/airnode-adapter/package.json @@ -20,7 +20,7 @@ }, "dependencies": { "@api3/ois": "2.0.0", - "@api3/promise-utils": "^0.3.0", + "@api3/promise-utils": "^0.4.0", "axios": "^1.3.4", "bignumber.js": "^9.1.1", "ethers": "^5.7.2", diff --git a/packages/airnode-admin/package.json b/packages/airnode-admin/package.json index 8f069397ff..c2f165fbcd 100644 --- a/packages/airnode-admin/package.json +++ b/packages/airnode-admin/package.json @@ -28,7 +28,7 @@ "@api3/airnode-protocol": "^0.10.0", "@api3/airnode-utilities": "^0.10.0", "@api3/airnode-validator": "^0.10.0", - "@api3/promise-utils": "^0.3.0", + "@api3/promise-utils": "^0.4.0", "ethers": "^5.7.2", "lodash": "^4.17.21", "yargs": "^17.7.1" diff --git a/packages/airnode-deployer/package.json b/packages/airnode-deployer/package.json index c634b5c3dc..09f2897632 100644 --- a/packages/airnode-deployer/package.json +++ b/packages/airnode-deployer/package.json @@ -28,7 +28,7 @@ "@api3/airnode-protocol": "^0.10.0", "@api3/airnode-utilities": "^0.10.0", "@api3/airnode-validator": "^0.10.0", - "@api3/promise-utils": "^0.3.0", + "@api3/promise-utils": "^0.4.0", "@aws-sdk/client-s3": "^3.295.0", "@aws-sdk/signature-v4-crt": "^3.295.0", "@google-cloud/storage": "^6.9.4", diff --git a/packages/airnode-node/package.json b/packages/airnode-node/package.json index 2cd46be6cd..0cd2472618 100644 --- a/packages/airnode-node/package.json +++ b/packages/airnode-node/package.json @@ -30,7 +30,7 @@ "@api3/airnode-utilities": "^0.10.0", "@api3/airnode-validator": "^0.10.0", "@api3/ois": "2.0.0", - "@api3/promise-utils": "^0.3.0", + "@api3/promise-utils": "^0.4.0", "@aws-sdk/client-lambda": "^3.295.0", "date-fns": "^2.29.3", "dotenv": "^16.0.3", diff --git a/packages/airnode-node/src/api/index.test.ts b/packages/airnode-node/src/api/index.test.ts index b0e8ca82d4..6d5061f335 100644 --- a/packages/airnode-node/src/api/index.test.ts +++ b/packages/airnode-node/src/api/index.test.ts @@ -4,7 +4,7 @@ import { AxiosError, AxiosHeaders } from 'axios'; import * as fixtures from '../../test/fixtures'; import { getExpectedTemplateIdV0 } from '../evm/templates'; import { ApiCallErrorResponse, RequestErrorMessage } from '../types'; -import { API_CALL_TIMEOUT } from '../constants'; +import { FIRST_API_CALL_TIMEOUT, SECOND_API_CALL_TIMEOUT } from '../constants'; import { callApi, verifyTemplateId } from '.'; describe('callApi', () => { @@ -63,7 +63,7 @@ describe('callApi', () => { }, ], }, - { timeout: API_CALL_TIMEOUT } + { timeout: FIRST_API_CALL_TIMEOUT } ); }); @@ -108,7 +108,7 @@ describe('callApi', () => { }, ], }, - { timeout: API_CALL_TIMEOUT } + { timeout: FIRST_API_CALL_TIMEOUT } ); }); @@ -146,7 +146,7 @@ describe('callApi', () => { }, ], }, - { timeout: API_CALL_TIMEOUT } + { timeout: FIRST_API_CALL_TIMEOUT } ); }); @@ -197,7 +197,7 @@ describe('callApi', () => { }, ], }, - { timeout: API_CALL_TIMEOUT } + { timeout: FIRST_API_CALL_TIMEOUT } ); }); @@ -216,7 +216,65 @@ describe('callApi', () => { expect.objectContaining({ parameters: { from: 'ETH', amount: '1' }, }), - { timeout: API_CALL_TIMEOUT } + { timeout: FIRST_API_CALL_TIMEOUT } + ); + }); + + it('retries the API call if the first attempt fails', async () => { + const spy = jest.spyOn(adapter, 'buildAndExecuteRequest') as any; + spy.mockRejectedValueOnce(new Error('First attempt failed')); + spy.mockResolvedValueOnce({ data: { price: 1000 } }); + const requestedGasPrice = '100000000'; + const requestedMinConfirmations = '0'; + const parameters = { + _type: 'int256', + _path: 'price', + from: 'ETH', + _gasPrice: requestedGasPrice, + _minConfirmations: requestedMinConfirmations, + }; + + const [logs, res] = await callApi({ + type: 'regular', + config: fixtures.buildConfig(), + aggregatedApiCall: fixtures.buildAggregatedRegularApiCall({ parameters }), + }); + + expect(logs).toEqual([]); + expect(res).toEqual({ + success: true, + data: { + encodedValue: '0x0000000000000000000000000000000000000000000000000000000005f5e100', + signature: + '0xe92f5ee40ddb5aa42cab65fcdc025008b2bc026af80a7c93a9aac4e474f8a88f4f2bd861b9cf9a2b050bf0fd13e9714c4575cebbea658d7501e98c0963a5a38b1c', + }, + // _minConfirmations is processed before making API calls + reservedParameterOverrides: { + gasPrice: requestedGasPrice, + }, + }); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledWith( + { + endpointName: 'convertToUSD', + ois: fixtures.buildOIS(), + parameters: { from: 'ETH', amount: '1' }, + metadata: { + chainId: '31337', + chainType: 'evm', + requestId: '0xf40127616f09d41b20891bcfd326957a0e3d5a5ecf659cff4d8106c04b024374', + requesterAddress: '0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512', + sponsorAddress: '0x2479808b1216E998309A727df8A0A98A1130A162', + sponsorWalletAddress: '0x1C1CEEF1a887eDeAB20219889971e1fd4645b55D', + }, + apiCredentials: [ + { + securitySchemeName: 'myApiSecurityScheme', + securitySchemeValue: 'supersecret', + }, + ], + }, + { timeout: SECOND_API_CALL_TIMEOUT } ); }); @@ -224,7 +282,7 @@ describe('callApi', () => { const spy = jest.spyOn(adapter, 'buildAndExecuteRequest') as any; const nonAxiosError = new Error('A non-axios error'); spy.mockRejectedValueOnce(nonAxiosError); - + spy.mockRejectedValueOnce(nonAxiosError); const parameters = { _type: 'int256', _path: 'unknown', from: 'ETH' }; const aggregatedApiCall = fixtures.buildAggregatedRegularApiCall({ parameters }); const [logs, res] = await callApi({ type: 'regular', config: fixtures.buildConfig(), aggregatedApiCall }); @@ -256,6 +314,7 @@ describe('callApi', () => { const spy = jest.spyOn(adapter, 'buildAndExecuteRequest') as any; const axiosError = e; spy.mockRejectedValueOnce(axiosError); + spy.mockRejectedValueOnce(axiosError); const parameters = { _type: 'int256', _path: 'unknown', from: 'ETH' }; const aggregatedApiCall = fixtures.buildAggregatedRegularApiCall({ parameters }); @@ -388,7 +447,7 @@ describe('callApi', () => { expect.objectContaining({ parameters: { from: 'BTC', source: 'airnode', amount: '1' }, }), - { timeout: API_CALL_TIMEOUT } + { timeout: FIRST_API_CALL_TIMEOUT } ); }); @@ -432,7 +491,7 @@ describe('callApi', () => { expect.objectContaining({ parameters: { from: 'ETH', amount: '1' }, }), - { timeout: API_CALL_TIMEOUT } + { timeout: FIRST_API_CALL_TIMEOUT } ); }); }); diff --git a/packages/airnode-node/src/api/index.ts b/packages/airnode-node/src/api/index.ts index 8ae423db30..e71e5c85b2 100644 --- a/packages/airnode-node/src/api/index.ts +++ b/packages/airnode-node/src/api/index.ts @@ -9,7 +9,7 @@ import compact from 'lodash/compact'; import { postProcessApiSpecifications, preProcessApiSpecifications } from './processing'; import { getAirnodeWalletFromPrivateKey, deriveSponsorWalletFromMnemonic } from '../evm'; import { getReservedParameters } from '../adapters/http/parameters'; -import { API_CALL_TIMEOUT } from '../constants'; +import { FIRST_API_CALL_TIMEOUT, SECOND_API_CALL_TIMEOUT } from '../constants'; import { isValidRequestId } from '../evm/verification'; import { getExpectedTemplateIdV0, getExpectedTemplateIdV1 } from '../evm/templates'; import { @@ -201,21 +201,27 @@ export async function performApiCall( payload: ApiCallPayload ): Promise> { const options = buildOptions(payload); - const timeout = API_CALL_TIMEOUT; - // We also pass the timeout to adapter to gracefully abort the request after the timeout - const goRes = await go(() => adapter.buildAndExecuteRequest(options, { timeout }), { - totalTimeoutMs: timeout, + // We also pass the timeout to adapter to gracefully abort the request after the timeout. + // timeout passed to adapter will cause axios socket to hang until the timeout is reached + // even if the totalTimeoutMs is reached and the 2nd attempt is made + const goAttempt1 = await go(() => adapter.buildAndExecuteRequest(options, { timeout: FIRST_API_CALL_TIMEOUT }), { + totalTimeoutMs: FIRST_API_CALL_TIMEOUT, }); - if (!goRes.success) { - const { aggregatedApiCall } = payload; - const log = logger.pend('ERROR', `Failed to call Endpoint:${aggregatedApiCall.endpointName}`, goRes.error); - // eslint-disable-next-line import/no-named-as-default-member - const axiosErrorMsg = axios.isAxiosError(goRes.error) ? errorMsgFromAxiosError(goRes.error) : ''; - const errorMessage = compact([RequestErrorMessage.ApiCallFailed, axiosErrorMsg]).join(' '); - return [[log], { success: false, errorMessage: errorMessage }]; + if (goAttempt1.success) { + return [[], { ...goAttempt1.data }]; } - - return [[], { ...goRes.data }]; + const goAttempt2 = await go(() => adapter.buildAndExecuteRequest(options, { timeout: SECOND_API_CALL_TIMEOUT }), { + totalTimeoutMs: SECOND_API_CALL_TIMEOUT, + }); + if (goAttempt2.success) { + return [[], { ...goAttempt2.data }]; + } + const { aggregatedApiCall } = payload; + const log = logger.pend('ERROR', `Failed to call Endpoint:${aggregatedApiCall.endpointName}`, goAttempt2.error); + // eslint-disable-next-line import/no-named-as-default-member + const axiosErrorMsg = axios.isAxiosError(goAttempt2.error) ? errorMsgFromAxiosError(goAttempt2.error) : ''; + const errorMessage = compact([RequestErrorMessage.ApiCallFailed, axiosErrorMsg]).join(' '); + return [[log], { success: false, errorMessage: errorMessage }]; } export async function processSuccessfulApiCall( diff --git a/packages/airnode-node/src/constants.ts b/packages/airnode-node/src/constants.ts index a173c2ccee..77e0dea4f0 100644 --- a/packages/airnode-node/src/constants.ts +++ b/packages/airnode-node/src/constants.ts @@ -1,5 +1,8 @@ // The maximum time a single API call has before it is timed out -export const API_CALL_TIMEOUT = 30_000; +export const FIRST_API_CALL_TIMEOUT = 10_000; + +// The maximum time a single API call has before it is timed out in the second attempt +export const SECOND_API_CALL_TIMEOUT = 20_000; // The number of past blocks to lookup when fetching Airnode RRP events. export const BLOCK_COUNT_HISTORY_LIMIT = 300; diff --git a/packages/airnode-node/src/coordinator/calls/coordinated-execution.test.ts b/packages/airnode-node/src/coordinator/calls/coordinated-execution.test.ts index d4d4c75e4a..691350dc16 100644 --- a/packages/airnode-node/src/coordinator/calls/coordinated-execution.test.ts +++ b/packages/airnode-node/src/coordinator/calls/coordinated-execution.test.ts @@ -96,6 +96,7 @@ describe('callApis', () => { jest.spyOn(validator, 'unsafeParseConfigWithSecrets').mockReturnValue(config); const spy = jest.spyOn(adapter, 'buildAndExecuteRequest') as jest.SpyInstance; spy.mockRejectedValueOnce(new Error('Unexpected error')); + spy.mockRejectedValueOnce(new Error('Unexpected error')); const parameters = { _type: 'int256', _path: 'prices.1' }; const aggregatedApiCall = fixtures.buildAggregatedRegularApiCall({ parameters }); const workerOpts = fixtures.buildWorkerOptions(); @@ -116,8 +117,8 @@ describe('callApis', () => { errorMessage: `${RequestErrorMessage.ApiCallFailed}`, }, ]); - expect(spy).toHaveBeenCalledTimes(1); - }); + expect(spy).toHaveBeenCalledTimes(2); + }, 35000); it('returns an error if the worker crashes', async () => { const spy = jest.spyOn(workers, 'spawn'); diff --git a/packages/airnode-node/test/e2e/signed-data.feature.ts b/packages/airnode-node/test/e2e/signed-data.feature.ts index 54cd2b42d2..b920bc4d68 100644 --- a/packages/airnode-node/test/e2e/signed-data.feature.ts +++ b/packages/airnode-node/test/e2e/signed-data.feature.ts @@ -35,6 +35,6 @@ it('makes a call for signed API data', async () => { // Verify that all internal parameters have been removed from the parameters forwarded to the API expect(adapter.buildAndExecuteRequest).toHaveBeenCalledWith( expect.objectContaining({ parameters: { from: 'ETH', amount: '1' } }), - { timeout: 30000 } + { timeout: 10000 } ); }); diff --git a/packages/airnode-utilities/package.json b/packages/airnode-utilities/package.json index 7d63edd8e0..874a76a1a1 100644 --- a/packages/airnode-utilities/package.json +++ b/packages/airnode-utilities/package.json @@ -19,7 +19,7 @@ "main": "dist/index.js", "dependencies": { "@api3/airnode-validator": "^0.10.0", - "@api3/promise-utils": "^0.3.0", + "@api3/promise-utils": "^0.4.0", "date-fns": "^2.29.3", "ethers": "^5.7.2" }, diff --git a/packages/airnode-validator/package.json b/packages/airnode-validator/package.json index 131ca0c190..7887c0ef0a 100644 --- a/packages/airnode-validator/package.json +++ b/packages/airnode-validator/package.json @@ -22,7 +22,7 @@ }, "dependencies": { "@api3/ois": "2.0.0", - "@api3/promise-utils": "^0.3.0", + "@api3/promise-utils": "^0.4.0", "dotenv": "^16.0.3", "ethers": "^5.7.2", "lodash": "^4.17.21", diff --git a/yarn.lock b/yarn.lock index e41b879fdf..74db3d233e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18,10 +18,10 @@ lodash "^4.17.21" zod "^3.20.6" -"@api3/promise-utils@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@api3/promise-utils/-/promise-utils-0.3.0.tgz#e7ebf92bfd8c1d39983321fc5445070c51fce176" - integrity sha512-fH3CzEcsCQjoX6BZ5M+3yRIXZ2zz4/nFdzKUB4wvn3KjvvzvroHFZrzhbKa4mB9E4AS0xnou1AXhlrnN5Fcy+A== +"@api3/promise-utils@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@api3/promise-utils/-/promise-utils-0.4.0.tgz#d1dcd77d74377b4fdb3071d2cc76d98c9151309c" + integrity sha512-+8fcNjjQeQAuuSXFwu8PMZcYzjwjDiGYcMUfAQ0lpREb1zHonwWZ2N0B9h/g1cvWzg9YhElbeb/SyhCrNm+b/A== "@aws-crypto/crc32@3.0.0": version "3.0.0"