diff --git a/README.md b/README.md index 3b3e61d..d01bb89 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,10 @@ Create a `config.json` file with the following structure: "bech32Prefix": "init", "chainId": "chain-2", "gasPrice": "0umin", - "restUri": "https://rest.chain-2.com", + "restUri": [ + "https://rest.chain-2.com", + "https://rest.chain-2.com/fallback" + ], "rpcUri": ["https://rpc.chain-2.com", "https://rpc.chain-2.com/fallback"], "wallets": [ { @@ -129,7 +132,7 @@ You can configure chains in two ways: - `CHAIN__CHAIN_ID`: Chain ID for the chain at index `` - `CHAIN__BECH32_PREFIX`: Bech32 prefix for the chain - `CHAIN__GAS_PRICE`: Gas price for the chain - - `CHAIN__REST_URI`: REST URI for the chain + - `CHAIN__REST_URI`: REST URI for the chain (can be a single URI string or JSON array of URI strings) - `CHAIN__RPC_URI`: RPC URI for the chain (can be a single URI string or array of URI strings) - `CHAIN__FEE_FILTER`: JSON string containing the fee filter configuration @@ -182,7 +185,7 @@ export CHAIN_0_FEE_FILTER='{"recvFee":[{"denom":"gas","amount":100}],"timeoutFee export CHAIN_1_CHAIN_ID=chain-2 export CHAIN_1_BECH32_PREFIX=init export CHAIN_1_GAS_PRICE=0umin -export CHAIN_1_REST_URI=https://rest.chain-2.com +export CHAIN_1_REST_URI='["https://rest.chain-2.com", "https://rest-fallback.chain-2.com"]' # JSON array of URI strings for fallback export CHAIN_1_RPC_URI='["https://rpc.chain-2.com", "https://rpc-fallback.chain-2.com"]' # JSON array of URI strings for fallback # Chain 2 wallet 1 configuration diff --git a/config.example.json b/config.example.json index 7c546c1..dc5882f 100644 --- a/config.example.json +++ b/config.example.json @@ -50,7 +50,10 @@ "bech32Prefix": "init", "chainId": "chain-2", "gasPrice": "0umin", - "restUri": "https://rest.chain-2.com", + "restUri": [ + "https://rest.chain-2.com", + "https://rest.chain-2.com/fallback" + ], "rpcUri": "https://rpc.chain-2.com", "wallets": [ { diff --git a/config.schema.json b/config.schema.json index cade593..46e9c56 100644 --- a/config.schema.json +++ b/config.schema.json @@ -121,8 +121,18 @@ "description": "gas price in format 0.1denom" }, "restUri": { - "type": "string", - "description": "cosmos rest api uri" + "description": "cosmos rest api uri", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] }, "rpcUri": { "description": "cosmos rest rpc uri", diff --git a/src/lib/config.spec.ts b/src/lib/config.spec.ts index 2add055..5599acd 100644 --- a/src/lib/config.spec.ts +++ b/src/lib/config.spec.ts @@ -170,6 +170,43 @@ describe('Configuration Loading', () => { ]) }) + it('should parse REST URI as array when formatted as JSON array', () => { + process.env.CHAIN_1_CHAIN_ID = 'test-chain-1' + process.env.CHAIN_1_BECH32_PREFIX = 'test' + process.env.CHAIN_1_GAS_PRICE = '0.01' + process.env.CHAIN_1_REST_URI = + '["http://localhost:1317", "http://localhost:1318"]' + process.env.CHAIN_1_RPC_URI = 'http://localhost:26657' + + // Setup wallet env vars + process.env.CHAIN_1_WALLET_1_KEY_TYPE = 'raw' + process.env.CHAIN_1_WALLET_1_KEY_PRIVATE_KEY = 'testkey' + + const config = loadEnvConfig() + + expect(config.chains?.[0].restUri).toEqual([ + 'http://localhost:1317', + 'http://localhost:1318', + ]) + }) + + it.each(['["http://localhost:1317",]', '[]', '[""]', '[123]'])( + 'should reject invalid URI arrays: %s', + (uriArray) => { + process.env.CHAIN_1_CHAIN_ID = 'test-chain-1' + process.env.CHAIN_1_BECH32_PREFIX = 'test' + process.env.CHAIN_1_GAS_PRICE = '0.01' + process.env.CHAIN_1_REST_URI = uriArray + process.env.CHAIN_1_RPC_URI = 'http://localhost:26657' + + // Setup wallet env vars + process.env.CHAIN_1_WALLET_1_KEY_TYPE = 'raw' + process.env.CHAIN_1_WALLET_1_KEY_PRIVATE_KEY = 'testkey' + + expect(() => loadEnvConfig()).toThrow(/URI array/) + } + ) + it('should parse fee filter from env vars', () => { const feeFilter: PacketFee = { recvFee: [{ denom: 'token', amount: 100 }], diff --git a/src/lib/config.ts b/src/lib/config.ts index afd4a54..b1be80a 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -31,6 +31,31 @@ export const safeJsonParse = (json: string, defaultValue: T): T => { } } +const parseUriConfig = (rawUri: string): string | string[] => { + const trimmedUri = rawUri.trim() + + if (trimmedUri.startsWith('[') && trimmedUri.endsWith(']')) { + let parsed: unknown + try { + parsed = JSON.parse(trimmedUri) + } catch (err) { + throw new Error(`Invalid URI array JSON: ${err}`) + } + + if ( + !Array.isArray(parsed) || + parsed.length === 0 || + parsed.some((uri) => typeof uri !== 'string' || uri.trim() === '') + ) { + throw new Error('URI array must contain at least one non-empty string') + } + + return parsed.map((uri) => uri.trim()) + } + + return trimmedUri +} + // load configuration from environment variables export const loadEnvConfig = (): Partial => { const envConfig: Partial = {} @@ -79,16 +104,15 @@ export const loadEnvConfig = (): Partial => { chain.bech32Prefix = env[`CHAIN_${index}_BECH32_PREFIX`] if (env[`CHAIN_${index}_GAS_PRICE`]) chain.gasPrice = env[`CHAIN_${index}_GAS_PRICE`] - if (env[`CHAIN_${index}_REST_URI`]) - chain.restUri = env[`CHAIN_${index}_REST_URI`] + + const rawRestUri = env[`CHAIN_${index}_REST_URI`]?.trim() + if (rawRestUri) { + chain.restUri = parseUriConfig(rawRestUri) + } const rawRpcUri = env[`CHAIN_${index}_RPC_URI`]?.trim() if (rawRpcUri) { - if (rawRpcUri.startsWith('[') && rawRpcUri.endsWith(']')) { - chain.rpcUri = safeJsonParse(rawRpcUri, [rawRpcUri]) - } else { - chain.rpcUri = rawRpcUri - } + chain.rpcUri = parseUriConfig(rawRpcUri) } // fee filter @@ -331,7 +355,7 @@ interface ChainConfig { bech32Prefix: string chainId: string gasPrice: string - restUri: string + restUri: string | string[] rpcUri: string | string[] wallets: WalletConfig[] feeFilter?: PacketFee diff --git a/src/lib/restClient.spec.ts b/src/lib/restClient.spec.ts new file mode 100644 index 0000000..3553965 --- /dev/null +++ b/src/lib/restClient.spec.ts @@ -0,0 +1,84 @@ +import nock from 'nock' +import { RESTClient } from './restClient' +import { logger } from './logger' + +const mockRestUris = [ + 'http://doi-rest-1.com', + 'http://moro-rest-2.com', + 'http://rene-rest-3.com', +] + +describe('RESTClient', () => { + afterEach(() => { + nock.cleanAll() + jest.restoreAllMocks() + }) + + it('should use the first endpoint successfully', async () => { + nock(mockRestUris[0]).get('/cosmos/base/node/v1beta1/config').reply(200, { + minimum_gas_price: '0.01uinit', + }) + + const client = new RESTClient(mockRestUris) + const result = await client.apiRequester.get<{ minimum_gas_price: string }>( + '/cosmos/base/node/v1beta1/config' + ) + + expect(result.minimum_gas_price).toBe('0.01uinit') + }) + + it('should fallback to the next endpoint if the first one fails', async () => { + nock(mockRestUris[0]).get('/cosmos/base/node/v1beta1/config').reply(500) + nock(mockRestUris[1]).get('/cosmos/base/node/v1beta1/config').reply(200, { + minimum_gas_price: '0.02uinit', + }) + + const loggerSpy = jest.spyOn(logger, 'error') + const client = new RESTClient(mockRestUris) + const result = await client.apiRequester.get<{ minimum_gas_price: string }>( + '/cosmos/base/node/v1beta1/config' + ) + + expect(result.minimum_gas_price).toBe('0.02uinit') + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining( + `[REST] Failed to request to ${mockRestUris[0]} - /cosmos/base/node/v1beta1/config` + ) + ) + }) + + it('should not fallback on client errors', async () => { + nock(mockRestUris[0]).get('/cosmos/base/node/v1beta1/config').reply(404) + const fallback = nock(mockRestUris[1]) + .get('/cosmos/base/node/v1beta1/config') + .reply(200, { + minimum_gas_price: '0.02uinit', + }) + + const client = new RESTClient(mockRestUris) + + await expect( + client.apiRequester.get('/cosmos/base/node/v1beta1/config') + ).rejects.toThrow() + expect(fallback.isDone()).toBe(false) + }) + + it('should prefer the last successful endpoint for later requests', async () => { + nock(mockRestUris[0]).get('/cosmos/base/node/v1beta1/config').reply(500) + nock(mockRestUris[1]).get('/cosmos/base/node/v1beta1/config').reply(200, { + minimum_gas_price: '0.02uinit', + }) + nock(mockRestUris[1]).get('/cosmos/base/node/v1beta1/config').reply(200, { + minimum_gas_price: '0.03uinit', + }) + + const client = new RESTClient(mockRestUris) + + await client.apiRequester.get('/cosmos/base/node/v1beta1/config') + const result = await client.apiRequester.get<{ minimum_gas_price: string }>( + '/cosmos/base/node/v1beta1/config' + ) + + expect(result.minimum_gas_price).toBe('0.03uinit') + }) +}) diff --git a/src/lib/restClient.ts b/src/lib/restClient.ts index 2902187..61911cb 100644 --- a/src/lib/restClient.ts +++ b/src/lib/restClient.ts @@ -6,19 +6,183 @@ import { RESTClient as RESTClient_, } from '@initia/initia.js' import { Order, State } from '@initia/initia.proto/ibc/core/channel/v1/channel' +import { logger } from './logger' import { ClientState, ConnectionInfo } from 'src/types' +export type RESTUri = string | string[] + +type APIRequesterConfig = ConstructorParameters[1] +type RESTParams = Parameters[1] +type RESTHeaders = Parameters[2] +type RESTData = Parameters[1] +type RESTRequesterOptions = APIRequester | APIRequesterConfig +interface RESTRequestState { + restUris: string[] + preferredIndex: number + requesterConfig?: APIRequesterConfig +} + +const MAX_RETRY = 10 + +const normalizeRestUris = (URL: RESTUri): string[] => { + const restUris = (Array.isArray(URL) ? URL : [URL]).map((uri) => uri.trim()) + + if (restUris.length === 0 || restUris.some((uri) => uri === '')) { + throw new Error( + 'REST URI list must contain at least one non-empty endpoint' + ) + } + + return restUris +} + +const getHttpStatus = (error: unknown): number | undefined => { + if (typeof error !== 'object' || error === null) { + return undefined + } + + const response = (error as { response?: { status?: unknown } }).response + return typeof response?.status === 'number' ? response.status : undefined +} + export class RESTClient extends RESTClient_ { public ibc: IbcAPI + constructor( - URL: string, + URL: RESTUri, config?: RESTClientConfig, - apiRequester?: APIRequester + requesterOptions?: RESTRequesterOptions ) { - super(URL, config, apiRequester) + const requestState: RESTRequestState = { + restUris: normalizeRestUris(URL), + preferredIndex: 0, + requesterConfig: + requesterOptions instanceof APIRequester ? undefined : requesterOptions, + } + const apiRequester = + requesterOptions instanceof APIRequester + ? requesterOptions + : RESTClient.createApiRequester(requestState) + + super(requestState.restUris[0], config, apiRequester) this.ibc = new IbcAPI(this.apiRequester) } + + private static createApiRequester(state: RESTRequestState): APIRequester { + const client = RESTClient.getRequester(state.restUris[0], state) + + client.getRaw = async ( + endpoint: string, + params?: RESTParams + ): Promise => { + const { response } = await RESTClient.request( + state, + 'getRaw', + endpoint, + params + ) + return response + } + + client.get = async ( + endpoint: string, + params?: RESTParams, + headers?: RESTHeaders + ): Promise => { + const { response } = await RESTClient.request( + state, + 'get', + endpoint, + params, + headers + ) + return response + } + + client.post = async (endpoint: string, data?: RESTData): Promise => { + const { response } = await RESTClient.request( + state, + 'post', + endpoint, + data + ) + return response + } + + return client + } + + private static getRequester( + uri: string, + state: RESTRequestState + ): APIRequester { + return new APIRequester(uri, state.requesterConfig) + } + + private static async request( + state: RESTRequestState, + method: 'getRaw' | 'get' | 'post', + endpoint: string, + query?: RESTParams | RESTData, + headers?: RESTHeaders + ): Promise<{ response: T; uri: string }> { + let retryCount = 0 + const startIndex = state.preferredIndex + let currentIndex = startIndex + + while (true) { + const uri = state.restUris[currentIndex] + + try { + let response: T + const requester = RESTClient.getRequester(uri, state) + + if (method === 'post') { + response = await requester.post(endpoint, query) + } else if (method === 'getRaw') { + response = await requester.getRaw(endpoint, query as RESTParams) + } else { + response = await requester.get( + endpoint, + query as RESTParams, + headers + ) + } + + state.preferredIndex = currentIndex + return { response, uri } + } catch (error) { + const errorContext = `[REST] Failed to request to ${uri} - ${endpoint}` + + logger.error(`${errorContext}: ${String(error)}`) + + const httpStatus = getHttpStatus(error) + if (httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500) { + throw error + } + + currentIndex = (currentIndex + 1) % state.restUris.length + + if (currentIndex === startIndex) { + let backoff = Math.pow(2, retryCount) * 1000 + if (backoff > 10000) { + backoff = 10 * 1000 + } + + logger.info(`[REST] All endpoints failed. Retrying in ${backoff}ms`) + await new Promise((resolve) => setTimeout(resolve, backoff)) + retryCount++ + if (retryCount > MAX_RETRY) { + logger.error(`[REST] Max Retry Reached.`) + throw error + } + } else { + logger.info(`[REST] Fallback to ${state.restUris[currentIndex]}`) + } + } + } + } } class IbcAPI extends IbcAPI_ { diff --git a/src/test/testSetup.ts b/src/test/testSetup.ts index 772b7c3..620cff2 100644 --- a/src/test/testSetup.ts +++ b/src/test/testSetup.ts @@ -10,6 +10,16 @@ import { setupServer } from 'msw/node' export let mockServers: { rest: RestMockServer }[] = [] +const firstUri = (uri: string | string[]): string => { + if (Array.isArray(uri)) { + if (uri.length === 0) { + throw new Error('restUri must contain at least one entry') + } + return uri[0] + } + return uri +} + const setup = () => { // remove db const dbPath = config.dbPath as string @@ -26,7 +36,8 @@ const setup = () => { mockServers = config.chains.map((chain, i) => { const index = i + 1 const counterpartyIndex = index + (index % 2 === 0 ? -1 : 1) - const restServer = new RestMockServer(chain.chainId, chain.restUri) + const restUri = firstUri(chain.restUri) + const restServer = new RestMockServer(chain.chainId, restUri) restServer.addClientState(`07-tendermint-${index}`, { client_state: { @@ -49,10 +60,8 @@ const setup = () => { handlers.push( http.get( - new URL( - '/ibc/core/connection/v1/connections/:connectionId', - chain.restUri - ).href, + new URL('/ibc/core/connection/v1/connections/:connectionId', restUri) + .href, ({ params }) => { const { connectionId } = params // ...and respond to them using this JSON response. @@ -72,8 +81,7 @@ const setup = () => { handlers.push( http.get( - new URL('/ibc/core/client/v1/client_states/:clientId', chain.restUri) - .href, + new URL('/ibc/core/client/v1/client_states/:clientId', restUri).href, ({ params }) => { const { clientId } = params @@ -93,7 +101,7 @@ const setup = () => { http.get( new URL( '/ibc/core/client/v1/consensus_states/:clientId/revision/0/height/0', - chain.restUri + restUri ).href, () => { return HttpResponse.json({ diff --git a/src/workers/index.ts b/src/workers/index.ts index 1203934..018c269 100644 --- a/src/workers/index.ts +++ b/src/workers/index.ts @@ -19,7 +19,6 @@ import { PacketWriteAckTable, } from 'src/types' import { - APIRequester, Coins, Key, MnemonicKey, @@ -95,11 +94,11 @@ export class WorkerController { chainId: chainConfig.chainId, gasPrices: chainConfig.gasPrice, }, - new APIRequester(chainConfig.restUri, { + { httpAgent: new http.Agent({ keepAlive: true }), httpsAgent: new https.Agent({ keepAlive: true }), timeout: 60000, - }) + } ) const rpc = new RPCClient(chainConfig.rpcUri) const latestHeight = await queryLatestHeight(rpc)