Skip to content

Commit

Permalink
feat: expose configurable request timeout
Browse files Browse the repository at this point in the history
- expose `TOKEN_METADATA_REQUEST_TIMEOUT` env var
- expose `--token-metadata-request-timeout` CLI arg
- use default timeout of 3 seconds for token metadata registry
- refactor `mockTokenRegistry()` with async handler
  • Loading branch information
Ivaylo Andonov committed Mar 22, 2023
1 parent 1fc35c2 commit cea5379
Show file tree
Hide file tree
Showing 11 changed files with 206 additions and 148 deletions.
17 changes: 13 additions & 4 deletions packages/cardano-services/src/Asset/CardanoTokenRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { TokenMetadataService } from './types';
import axios, { AxiosInstance } from 'axios';

export const DEFAULT_TOKEN_METADATA_CACHE_TTL = 600;
export const DEFAULT_TOKEN_METADATA_REQUEST_TIMEOUT = 3 * 1000;
export const DEFAULT_TOKEN_METADATA_SERVER_URL = 'https://tokens.cardano.org';

interface NumberValue {
Expand Down Expand Up @@ -54,6 +55,11 @@ export interface CardanoTokenRegistryConfiguration {
* The Cardano Token Registry public API base URL. Default: https://tokens.cardano.org
*/
tokenMetadataServerUrl?: string;

/**
* The HTTP request timeout value
*/
tokenMetadataRequestTimeout?: number;
}

interface CardanoTokenRegistryConfigurationWithRequired extends CardanoTokenRegistryConfiguration {
Expand Down Expand Up @@ -98,12 +104,16 @@ export class CardanoTokenRegistry implements TokenMetadataService {
constructor({ cache, logger }: CardanoTokenRegistryDependencies, config: CardanoTokenRegistryConfiguration = {}) {
const defaultConfig: CardanoTokenRegistryConfigurationWithRequired = {
tokenMetadataCacheTTL: DEFAULT_TOKEN_METADATA_CACHE_TTL,
tokenMetadataRequestTimeout: DEFAULT_TOKEN_METADATA_REQUEST_TIMEOUT,
tokenMetadataServerUrl: DEFAULT_TOKEN_METADATA_SERVER_URL,
...config
};

this.#cache = cache || new InMemoryCache(defaultConfig.tokenMetadataCacheTTL);
this.#axiosClient = axios.create({ baseURL: defaultConfig.tokenMetadataServerUrl });
this.#axiosClient = axios.create({
baseURL: defaultConfig.tokenMetadataServerUrl,
timeout: defaultConfig.tokenMetadataRequestTimeout
});
this.#logger = logger;
}

Expand Down Expand Up @@ -150,12 +160,11 @@ export class CardanoTokenRegistry implements TokenMetadataService {
} catch (error) {
if (axios.isAxiosError(error)) {
throw new ProviderError(
ProviderFailure.ConnectionFailure,
ProviderFailure.Unhealthy,
error,
'CardanoTokenRegistry failed to fetch asset metadata from the token registry server'
`CardanoTokenRegistry failed to fetch asset metadata from the token registry server due to: ${error.message}`
);
}

throw error;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ export class DbSyncAssetProvider extends DbSyncProvider() implements AssetProvid
try {
assetInfo.tokenMetadata = (await this.#dependencies.tokenMetadataService.getTokenMetadata([assetId]))[0];
} catch (error) {
if (error instanceof ProviderError && error.reason === ProviderFailure.ConnectionFailure) {
if (error instanceof ProviderError && error.reason === ProviderFailure.Unhealthy) {
this.logger.error(`Failed to fetch token metadata for asset with ${assetId} due to: ${error.message}`);
assetInfo.tokenMetadata = undefined;
} else {
throw error;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export type ProviderServerArgs = CommonProgramOptions &
disableStakePoolMetricApy?: boolean;
tokenMetadataCacheTTL?: number;
tokenMetadataServerUrl?: string;
tokenMetadataRequestTimeout?: number;
epochPollInterval: number;
dbCacheTtl: number;
useBlockfrost?: boolean;
Expand Down
15 changes: 14 additions & 1 deletion packages/cardano-services/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ import {
} from './Program';
import { Command, Option } from 'commander';
import { DB_CACHE_TTL_DEFAULT } from './InMemoryCache';
import { DEFAULT_TOKEN_METADATA_CACHE_TTL, DEFAULT_TOKEN_METADATA_SERVER_URL } from './Asset';
import {
DEFAULT_TOKEN_METADATA_CACHE_TTL,
DEFAULT_TOKEN_METADATA_REQUEST_TIMEOUT,
DEFAULT_TOKEN_METADATA_SERVER_URL
} from './Asset';
import { EPOCH_POLL_INTERVAL_DEFAULT } from './util';
import { HttpServer } from './Http';
import { URL } from 'url';
Expand Down Expand Up @@ -168,6 +172,15 @@ withCommonOptions(
.default(DEFAULT_TOKEN_METADATA_CACHE_TTL)
.argParser(cacheTtlValidator)
)
.addOption(
new Option(
'--token-metadata-request-timeout <tokenMetadataRequestTimeout>',
ProviderServerOptionDescriptions.PaginationPageSizeLimit
)
.env('TOKEN_METADATA_REQUEST_TIMEOUT')
.default(DEFAULT_TOKEN_METADATA_REQUEST_TIMEOUT)
.argParser((interval) => Number.parseInt(interval, 10))
)
.addOption(
new Option('--use-blockfrost <true/false>', ProviderServerOptionDescriptions.UseBlockfrost)
.env('USE_BLOCKFROST')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ describe('AssetHttpService', () => {

describe('healthy state', () => {
beforeAll(async () => {
({ closeMock, serverUrl } = await mockTokenRegistry(() => ({})));
({ closeMock, serverUrl } = await mockTokenRegistry(async () => ({})));
db = new Pool({ connectionString: process.env.POSTGRES_CONNECTION_STRING });
ntfMetadataService = new DbSyncNftMetadataService({
db,
Expand Down
108 changes: 54 additions & 54 deletions packages/cardano-services/test/Asset/CardanoTokenRegistry.test.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,18 @@
/* eslint-disable max-len */
import { Cardano, ProviderError, ProviderFailure } from '@cardano-sdk/core';
import { CardanoTokenRegistry, toCoreTokenMetadata } from '../../src/Asset';
import { CardanoTokenRegistry, DEFAULT_TOKEN_METADATA_REQUEST_TIMEOUT, toCoreTokenMetadata } from '../../src/Asset';
import { InMemoryCache, Key } from '../../src/InMemoryCache';
import { createGenericMockServer, logger } from '@cardano-sdk/util-dev';

const mockResults: Record<string, unknown> = {
'50fdcdbfa3154db86a87e4b5697ae30d272e0bbcfa8122efd3e301cb6d616361726f6e2d63616b65': {
description: { value: 'This is my first NFT of the macaron cake' },
name: { value: 'macaron cake token' },
subject: '50fdcdbfa3154db86a87e4b5697ae30d272e0bbcfa8122efd3e301cb6d616361726f6e2d63616b65'
},
f43a62fdc3965df486de8a0d32fe800963589c41b38946602a0dc53541474958: {
decimals: { value: 8 },
description: { value: 'SingularityNET' },
logo: { value: 'testLogo' },
name: { value: 'SingularityNet AGIX Token' },
subject: 'f43a62fdc3965df486de8a0d32fe800963589c41b38946602a0dc53541474958',
ticker: { value: 'AGIX' },
url: { value: 'https://singularitynet.io/' }
}
};

export const mockTokenRegistry = createGenericMockServer((handler) => async (req, res) => {
const { body, code } = handler(req);

res.setHeader('Content-Type', 'application/json');

if (body) {
res.statusCode = code || 200;

return res.end(JSON.stringify(body));
}

const buffers: Buffer[] = [];
for await (const chunk of req) buffers.push(chunk);
const data = Buffer.concat(buffers).toString();
const subjects: unknown[] = [];

for (const subject of JSON.parse(data).subjects) {
const mockResult = mockResults[subject as string];

if (mockResult) subjects.push(mockResult);
}

return res.end(JSON.stringify({ subjects }));
});
import { logger } from '@cardano-sdk/util-dev';
import { mockTokenRegistry } from './fixtures/mocks';
import { sleep } from '../util';

const testDescription = 'test description';
const testName = 'test name';

describe('CardanoTokenRegistry', () => {
const invalidAssetId = Cardano.AssetId('0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef');
const validAssetId = Cardano.AssetId('f43a62fdc3965df486de8a0d32fe800963589c41b38946602a0dc53541474958');
const defaultTimeout = DEFAULT_TOKEN_METADATA_REQUEST_TIMEOUT;

describe('toCoreTokenMetadata', () => {
it('complete attributes', () =>
Expand Down Expand Up @@ -89,7 +51,7 @@ describe('CardanoTokenRegistry', () => {
let tokenRegistry = new CardanoTokenRegistry({ logger });

beforeAll(async () => {
({ closeMock, serverUrl } = await mockTokenRegistry(() => ({})));
({ closeMock, serverUrl } = await mockTokenRegistry(async () => ({})));
tokenRegistry = new CardanoTokenRegistry({ logger }, { tokenMetadataServerUrl: serverUrl });
});

Expand Down Expand Up @@ -148,7 +110,7 @@ describe('CardanoTokenRegistry', () => {
let tokenRegistry = new CardanoTokenRegistry({ logger });

beforeAll(async () => {
({ closeMock, serverUrl } = await mockTokenRegistry(() => ({})));
({ closeMock, serverUrl } = await mockTokenRegistry(async () => ({})));
tokenRegistry = new CardanoTokenRegistry(
{ cache: new TestInMemoryCache(60), logger },
{ tokenMetadataServerUrl: serverUrl }
Expand Down Expand Up @@ -179,8 +141,11 @@ describe('CardanoTokenRegistry', () => {
afterEach(async () => await closeMock());

it('null record', async () => {
({ closeMock, serverUrl } = await mockTokenRegistry(() => ({ body: { subjects: [null] } })));
const tokenRegistry = new CardanoTokenRegistry({ logger }, { tokenMetadataServerUrl: serverUrl });
({ closeMock, serverUrl } = await mockTokenRegistry(async () => ({ body: { subjects: [null] } })));
const tokenRegistry = new CardanoTokenRegistry(
{ logger },
{ tokenMetadataRequestTimeout: defaultTimeout, tokenMetadataServerUrl: serverUrl }
);

await expect(tokenRegistry.getTokenMetadata([validAssetId])).rejects.toThrow(
new ProviderError(
Expand All @@ -193,8 +158,11 @@ describe('CardanoTokenRegistry', () => {

it('record without the subject property', async () => {
const record = { test: 'test' };
({ closeMock, serverUrl } = await mockTokenRegistry(() => ({ body: { subjects: [record] } })));
const tokenRegistry = new CardanoTokenRegistry({ logger }, { tokenMetadataServerUrl: serverUrl });
({ closeMock, serverUrl } = await mockTokenRegistry(async () => ({ body: { subjects: [record] } })));
const tokenRegistry = new CardanoTokenRegistry(
{ logger },
{ tokenMetadataRequestTimeout: defaultTimeout, tokenMetadataServerUrl: serverUrl }
);

await expect(tokenRegistry.getTokenMetadata([validAssetId])).rejects.toThrow(
new ProviderError(
Expand All @@ -210,7 +178,7 @@ describe('CardanoTokenRegistry', () => {
const succeededMetadata = { name: 'test' };

let alreadyCalled = false;
const record = () => {
const record = async () => {
if (alreadyCalled) return { body: {}, code: 500 };

alreadyCalled = true;
Expand All @@ -225,15 +193,47 @@ describe('CardanoTokenRegistry', () => {
};

({ closeMock, serverUrl } = await mockTokenRegistry(record));
const tokenRegistry = new CardanoTokenRegistry({ logger }, { tokenMetadataServerUrl: serverUrl });
const tokenRegistry = new CardanoTokenRegistry(
{ logger },
{ tokenMetadataRequestTimeout: defaultTimeout, tokenMetadataServerUrl: serverUrl }
);
const firstSucceedResult = await tokenRegistry.getTokenMetadata([invalidAssetId, validAssetId]);
expect(firstSucceedResult).toEqual([failedMetadata, succeededMetadata]);

await expect(tokenRegistry.getTokenMetadata([invalidAssetId, validAssetId])).rejects.toThrow(
new ProviderError(
ProviderFailure.ConnectionFailure,
ProviderFailure.Unhealthy,
null,
'CardanoTokenRegistry failed to fetch asset metadata from the token registry server due to: Request failed with status code 500'
)
);
});

it('timeout server error', async () => {
const exceededTimeout = defaultTimeout + 1000;
const record = async () => {
await sleep(exceededTimeout);

return {
body: {
subjects: [
{ name: { value: 'test' }, subject: 'f43a62fdc3965df486de8a0d32fe800963589c41b38946602a0dc53541474958' }
]
}
};
};

({ closeMock, serverUrl } = await mockTokenRegistry(record));
const tokenRegistry = new CardanoTokenRegistry(
{ logger },
{ tokenMetadataRequestTimeout: defaultTimeout, tokenMetadataServerUrl: serverUrl }
);

await expect(tokenRegistry.getTokenMetadata([validAssetId])).rejects.toThrow(
new ProviderError(
ProviderFailure.Unhealthy,
null,
'CardanoTokenRegistry failed to fetch asset metadata from the token registry server'
`CardanoTokenRegistry failed to fetch asset metadata from the token registry server due to: timeout of ${defaultTimeout}ms exceeded`
)
);
});
Expand Down
48 changes: 41 additions & 7 deletions packages/cardano-services/test/Asset/DbSyncAssetProvider.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/* eslint-disable max-len */
/* eslint-disable @typescript-eslint/no-shadow */
import { AssetFixtureBuilder, AssetWith } from './fixtures/FixtureBuilder';
import { Cardano, ProviderError, ProviderFailure } from '@cardano-sdk/core';
import {
CardanoTokenRegistry,
DEFAULT_TOKEN_METADATA_REQUEST_TIMEOUT,
DbSyncAssetProvider,
DbSyncNftMetadataService,
NftMetadataService,
Expand All @@ -14,8 +16,10 @@ import { createDbSyncMetadataService } from '../../src/Metadata';
import { logger } from '@cardano-sdk/util-dev';
import { mockCardanoNode } from '../../../core/test/CardanoNode/mocks';
import { mockTokenRegistry } from './fixtures/mocks';
import { sleep } from '../util';

export const notValidAssetId = Cardano.AssetId('0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef');
const defaultTimeout = DEFAULT_TOKEN_METADATA_REQUEST_TIMEOUT;

describe('DbSyncAssetProvider', () => {
let closeMock: () => Promise<void> = jest.fn();
Expand All @@ -28,15 +32,18 @@ describe('DbSyncAssetProvider', () => {
let fixtureBuilder: AssetFixtureBuilder;

beforeAll(async () => {
({ closeMock, serverUrl } = await mockTokenRegistry(() => ({})));
({ closeMock, serverUrl } = await mockTokenRegistry(async () => ({})));
db = new Pool({ connectionString: process.env.POSTGRES_CONNECTION_STRING });
cardanoNode = mockCardanoNode() as unknown as OgmiosCardanoNode;
ntfMetadataService = new DbSyncNftMetadataService({
db,
logger,
metadataService: createDbSyncMetadataService(db, logger)
});
tokenMetadataService = new CardanoTokenRegistry({ logger }, { tokenMetadataServerUrl: serverUrl });
tokenMetadataService = new CardanoTokenRegistry(
{ logger },
{ tokenMetadataRequestTimeout: defaultTimeout, tokenMetadataServerUrl: serverUrl }
);
provider = new DbSyncAssetProvider({ cardanoNode, db, logger, ntfMetadataService, tokenMetadataService });
fixtureBuilder = new AssetFixtureBuilder(db, logger);
});
Expand Down Expand Up @@ -73,14 +80,41 @@ describe('DbSyncAssetProvider', () => {
expect(asset.history).toEqual(history);
expect(asset.nftMetadata).toStrictEqual(assets[0].metadata);
expect(asset.tokenMetadata).toStrictEqual({
desc: 'This is my first NFT of the macaron cake',
name: 'macaron cake token'
desc: 'This is my second NFT',
name: 'Bored Ape'
});
});
it('returns undefined asset token metadata if the token registry throws a server internal error', async () => {
const { serverUrl, closeMock } = await mockTokenRegistry(async () => ({ body: {}, code: 500 }));
const tokenMetadataService = new CardanoTokenRegistry(
{ logger },
{ tokenMetadataRequestTimeout: defaultTimeout, tokenMetadataServerUrl: serverUrl }
);

provider = new DbSyncAssetProvider({ cardanoNode, db, logger, ntfMetadataService, tokenMetadataService });

const assets = await fixtureBuilder.getAssets(1, { with: [AssetWith.CIP25Metadata] });
const asset = await provider.getAsset({
assetId: assets[0].id,
extraData: { tokenMetadata: true }
});
expect(asset.tokenMetadata).toBeUndefined();
tokenMetadataService.shutdown();
await closeMock();
});
it('returns undefined asset token metadata if the token registry throws a network error', async () => {
const { serverUrl, closeMock } = await mockTokenRegistry(() => ({ body: {}, code: 500 }));
const tokenMetadataService = new CardanoTokenRegistry({ logger }, { tokenMetadataServerUrl: serverUrl });

it('returns undefined asset token metadata and load it internally if the token registry throws a timeout error', async () => {
const exceededTimeout = DEFAULT_TOKEN_METADATA_REQUEST_TIMEOUT + 1000;
const handler = async () => {
await sleep(exceededTimeout);
return { body: { subjects: [] } };
};

const { serverUrl, closeMock } = await mockTokenRegistry(handler);
const tokenMetadataService = new CardanoTokenRegistry(
{ logger },
{ tokenMetadataRequestTimeout: defaultTimeout, tokenMetadataServerUrl: serverUrl }
);
provider = new DbSyncAssetProvider({ cardanoNode, db, logger, ntfMetadataService, tokenMetadataService });

const assets = await fixtureBuilder.getAssets(1, { with: [AssetWith.CIP25Metadata] });
Expand Down
Loading

0 comments on commit cea5379

Please sign in to comment.