Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -937,11 +937,6 @@
"count": 2
}
},
"packages/stellar-wallet-snap/snap.config.ts": {
"import-x/no-nodejs-modules": {
"count": 1
}
},
"packages/stellar-wallet-snap/src/handlers/accountResolver.test.ts": {
"@typescript-eslint/explicit-function-return-type": {
"count": 1
Expand Down
4 changes: 4 additions & 0 deletions packages/stellar-wallet-snap/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Retry transient Token API, Price API, and Security Alerts API failures with bounded timeouts and circuit breaking ([#534](https://github.com/MetaMask/internal-snaps/pull/534))

## [2.0.0]

### Added
Expand Down
3 changes: 2 additions & 1 deletion packages/stellar-wallet-snap/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@
"lodash": "^4.17.21",
"oxfmt": "^0.70.0",
"qrcode-generator": "^2.0.4",
"ts-jest": "^29.4.14"
"ts-jest": "^29.4.14",
"webpack": "5.107.2"
},
"engines": {
"node": "^22.14.0 || ^24"
Expand Down
8 changes: 7 additions & 1 deletion packages/stellar-wallet-snap/snap.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
/* eslint-disable no-restricted-globals -- Snap configuration executes in Node.js. */
/* eslint-disable import-x/no-nodejs-modules, no-restricted-globals -- Snap configuration executes in Node.js. */
import type { SnapConfig } from '@metamask/snaps-cli';
import { merge } from '@metamask/snaps-cli';
import { config as dotenv } from 'dotenv';
import { resolve } from 'path';
import { ProvidePlugin } from 'webpack';

dotenv();

Expand Down Expand Up @@ -65,6 +66,11 @@ const config: SnapConfig = {
},
customizeWebpackConfig: (webpackConfig) =>
merge(webpackConfig, {
plugins: [
new ProvidePlugin({
performance: '@metamask/snap-networks-utils/performance-shim',
}),
],
resolve: {
alias: {
eventsource: resolve(__dirname, 'src/shims/eventsource.ts'),
Expand Down
2 changes: 1 addition & 1 deletion packages/stellar-wallet-snap/snap.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"url": "https://github.com/MetaMask/internal-snaps.git"
},
"source": {
"shasum": "cEQYJlJYtG1e4ReIBhEmq5NRmPDPHYfGEvjyzvPR/z0=",
"shasum": "d78hKkUMXp8HjPdIlIMt0sEsfMFC0uGBbiybKrkkARI=",
"location": {
"npm": {
"filePath": "dist/bundle.js",
Expand Down
5 changes: 5 additions & 0 deletions packages/stellar-wallet-snap/src/context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
AnalyticsService,
createFetchPolicy,
SecurityAlertsApiClient,
State,
AssetsProvider,
Expand Down Expand Up @@ -153,6 +154,10 @@ const priceService = new PriceService({

const securityAlertsApiClient = new SecurityAlertsApiClient({
scanUrl: `${AppConfig.api.securityAlertsApi.baseUrl}/stellar/transaction/scan`,
policy: createFetchPolicy({
service: 'security_alerts_api',
logger: logger.withPrefix('[🛡️ SecurityAlertsApiClient]'),
}),
Comment on lines +157 to +160

@stanleyyconsensys stanleyyconsensys Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question:
why we dont move it into SecurityAlertsApiClient? or we leave it to client to config?

Nit:
shall we provide a prefix for the logger (alignment)
logger.withPrefix('[🛡️ SecurityAlertsApiClient]'),

});

const transactionScanService = new TransactionScanService({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
} from '../../../utils/errors';
import { TokenApiClient } from './TokenApiClient';

jest.mock('../../../utils/logger');

const baseUrl = 'https://tokens.test';

const classicAssetId =
Expand All @@ -28,8 +30,9 @@ const jsonResponse = (
return {
ok,
status,
json: async () => body,
} as Response;
headers: { get: () => null },
text: async () => JSON.stringify(body),
} as unknown as Response;
};

const tokenMetadataResponse = (
Expand Down Expand Up @@ -98,14 +101,33 @@ describe('TokenApiClient', () => {
});

it('throws HttpResponseException when response is not ok', async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse([], { ok: false, status: 503 }),
);
mockFetch.mockResolvedValue(jsonResponse([], { ok: false, status: 503 }));

const client = createClient();
await expect(
client.getAssetsByAssetIds([classicAssetId]),
).rejects.toThrow(HttpResponseException);
expect(mockFetch).toHaveBeenCalledTimes(4);
});

it('retries when reading the response body fails', async () => {
const body = [
tokenMetadataResponse(classicAssetId, {
name: 'USD Coin',
symbol: 'USDC',
}),
];
mockFetch
.mockResolvedValueOnce({
ok: true,
text: jest.fn().mockRejectedValueOnce(new Error('Body failed')),
} as unknown as Response)
.mockResolvedValueOnce(jsonResponse(body));

expect(
await createClient().getAssetsByAssetIds([classicAssetId]),
).toStrictEqual(body);
expect(mockFetch).toHaveBeenCalledTimes(2);
});

it('throws InvalidHttpResponseException when response body is invalid', async () => {
Expand All @@ -121,12 +143,13 @@ describe('TokenApiClient', () => {
const networkError = Object.assign(new Error('network down'), {
cause: { code: 'ECONNREFUSED' },
});
mockFetch.mockRejectedValueOnce(networkError);
mockFetch.mockRejectedValue(networkError);

const client = createClient();
await expect(
client.getAssetsByAssetIds([classicAssetId]),
).rejects.toThrow(HttpException);
expect(mockFetch).toHaveBeenCalledTimes(4);
});
});

Expand Down Expand Up @@ -175,14 +198,15 @@ describe('TokenApiClient', () => {
});

it('throws HttpResponseException on HTTP error', async () => {
mockFetch.mockResolvedValueOnce(
mockFetch.mockResolvedValue(
jsonResponse({ data: [] }, { ok: false, status: 502 }),
);

const client = createClient();
await expect(
client.getAssetsByChainId(KnownCaip2ChainId.Mainnet),
).rejects.toThrow(HttpResponseException);
expect(mockFetch).toHaveBeenCalledTimes(4);
});

it('throws InvalidHttpResponseException when body does not match schema', async () => {
Expand All @@ -198,12 +222,13 @@ describe('TokenApiClient', () => {
const networkError = Object.assign(new Error('network down'), {
cause: { code: 'ECONNREFUSED' },
});
mockFetch.mockRejectedValueOnce(networkError);
mockFetch.mockRejectedValue(networkError);

const client = createClient();
await expect(
client.getAssetsByChainId(KnownCaip2ChainId.Mainnet),
).rejects.toThrow(HttpException);
expect(mockFetch).toHaveBeenCalledTimes(4);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { buildUrl } from '@metamask/snap-networks-utils';
import type { FetchPolicy, Logger } from '@metamask/snap-networks-utils';
import {
buildUrl,
createFetchPolicy,
fetchWithPolicy,
} from '@metamask/snap-networks-utils';
import { array } from '@metamask/superstruct';

import {
Expand All @@ -15,11 +20,11 @@ import {
assertHttpRequestParams,
assertHttpResponse,
HttpException,
HttpResponseException,
InvalidHttpRequestParamsException,
InvalidHttpResponseException,
normalizeHttpException,
} from '../../../utils/errors';
import { logger } from '../../../utils/logger';
import type {
TokenMetadataByAssetIdsResponse,
TokenMetadataByChainIdResponse,
Expand All @@ -35,16 +40,23 @@ export class TokenApiClient {

readonly #baseUrl: string;

readonly #policy: FetchPolicy;

constructor(
{
baseUrl,
}: {
baseUrl: string;
},
_fetch: typeof globalThis.fetch = globalThis.fetch,
_logger: Logger = logger,
) {
this.#fetch = _fetch;
this.#baseUrl = baseUrl;
this.#policy = createFetchPolicy({
service: 'token_api',
logger: _logger,
});
}

async getAssetsByAssetIds(
Expand All @@ -64,13 +76,13 @@ export class TokenApiClient {
},
});

const response = await this.#fetch(url);

if (!response.ok) {
throw new HttpResponseException(response.status);
}

const data = await response.json();
const body = await fetchWithPolicy(
this.#policy,
url,
undefined,
this.#fetch,
);
const data = JSON.parse(body);

assertHttpResponse(data, TokenMetadataByAssetIdsResponseStruct);

Expand Down Expand Up @@ -108,13 +120,13 @@ export class TokenApiClient {
},
});

const response = await this.#fetch(url);

if (!response.ok) {
throw new HttpResponseException(response.status);
}

const data = await response.json();
const body = await fetchWithPolicy(
this.#policy,
url,
undefined,
this.#fetch,
);
const data = JSON.parse(body);

assertHttpResponse(data, TokenMetadataByChainIdResponseStruct);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ const jsonResponse = (
return {
ok,
status,
json: async () => body,
} as Response;
headers: { get: () => null },
text: async () => JSON.stringify(body),
} as unknown as Response;
};

const baseUrl = 'https://price.test';
Expand Down Expand Up @@ -135,14 +136,30 @@ describe('PriceApiClient', () => {
});

it('throws HttpResponseException when response is not ok', async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse({}, { ok: false, status: 503 }),
);
mockFetch.mockResolvedValue(jsonResponse({}, { ok: false, status: 503 }));

const client = createClient();
await expect(client.getSpotPrices([stellarClassicUsdc])).rejects.toThrow(
HttpResponseException,
);
expect(mockFetch).toHaveBeenCalledTimes(4);
});

it('retries when reading the response body fails', async () => {
const spotBody = {
[stellarClassicUsdc]: minimalSpotPrice('usdc', 1),
};
mockFetch
.mockResolvedValueOnce({
ok: true,
text: jest.fn().mockRejectedValueOnce(new Error('Body failed')),
} as unknown as Response)
.mockResolvedValueOnce(jsonResponse(spotBody));

expect(
await createClient().getSpotPrices([stellarClassicUsdc]),
).toStrictEqual(spotBody);
expect(mockFetch).toHaveBeenCalledTimes(2);
});

it('rejects with InvalidHttpResponseException when response body fails validation', async () => {
Expand All @@ -158,12 +175,13 @@ describe('PriceApiClient', () => {
const networkError = Object.assign(new Error('network down'), {
cause: { code: 'ECONNREFUSED' },
});
mockFetch.mockRejectedValueOnce(networkError);
mockFetch.mockRejectedValue(networkError);

const client = createClient();
await expect(client.getSpotPrices([stellarClassicUsdc])).rejects.toThrow(
HttpException,
);
expect(mockFetch).toHaveBeenCalledTimes(4);
});
});
});
Loading
Loading