Skip to content
Draft
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 @@ -2030,11 +2030,6 @@
"count": 1
}
},
"packages/ramps-controller/src/RampsController.ts": {
"no-restricted-syntax": {
"count": 1
}
},
"packages/ramps-controller/src/TransakService.test.ts": {
"no-restricted-syntax": {
"count": 4
Expand Down
4 changes: 4 additions & 0 deletions packages/ramps-controller/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]

### Fixed

- Tag circuit-breaker errors in `RampsController` with a stable `CIRCUIT_BREAKER_OPEN` error key so clients can localize the fallback copy without depending on internal Cockatiel text ([#8596](https://github.com/MetaMask/core/pull/8596)).

## [13.2.0]

### Changed
Expand Down
207 changes: 207 additions & 0 deletions packages/ramps-controller/src/RampsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
getDefaultRampsControllerState,
RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS,
} from './RampsController';
import { RAMPS_ERROR_CODES } from './rampsErrorCodes';
import type {
Country,
TokensResponse,
Expand Down Expand Up @@ -61,6 +62,9 @@ import type {
} from './TransakService';

describe('RampsController', () => {
const circuitBreakerOpenErrorMessage =
'Execution prevented because the circuit breaker is open';

describe('normalizeProviderCode', () => {
it('strips /providers/ prefix', () => {
expect(normalizeProviderCode('/providers/transak')).toBe('transak');
Expand Down Expand Up @@ -1075,6 +1079,94 @@ describe('RampsController', () => {
});
});

it('tags circuit breaker errors with a localized error key', async () => {
await withController(async ({ controller, rootMessenger }) => {
const error = new Error(circuitBreakerOpenErrorMessage);
const fetcher = async (): Promise<string> => {
throw error;
};

await expect(
rootMessenger.call(
'RampsController:executeRequest',
'error-key-circuit-breaker',
fetcher,
),
).rejects.toMatchObject({
errorKey: RAMPS_ERROR_CODES.CIRCUIT_BREAKER_OPEN,
message: circuitBreakerOpenErrorMessage,
});

const requestState =
controller.state.requests['error-key-circuit-breaker'];
expect(requestState?.status).toBe(RequestStatus.ERROR);
expect(requestState?.error).toBe(circuitBreakerOpenErrorMessage);
expect(requestState?.errorKey).toBe(
RAMPS_ERROR_CODES.CIRCUIT_BREAKER_OPEN,
);
expect(error.message).toBe(circuitBreakerOpenErrorMessage);
expect((error as Error & { errorKey?: string }).errorKey).toBe(
RAMPS_ERROR_CODES.CIRCUIT_BREAKER_OPEN,
);
});
});

it('wraps string circuit breaker errors with a localized error key', async () => {
await withController(async ({ controller, rootMessenger }) => {
const fetcher = async (): Promise<string> => {
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw circuitBreakerOpenErrorMessage;
};

await expect(
rootMessenger.call(
'RampsController:executeRequest',
'error-key-circuit-breaker-string',
fetcher,
),
).rejects.toMatchObject({
errorKey: RAMPS_ERROR_CODES.CIRCUIT_BREAKER_OPEN,
message: circuitBreakerOpenErrorMessage,
});

const requestState =
controller.state.requests['error-key-circuit-breaker-string'];
expect(requestState?.status).toBe(RequestStatus.ERROR);
expect(requestState?.error).toBe(circuitBreakerOpenErrorMessage);
expect(requestState?.errorKey).toBe(
RAMPS_ERROR_CODES.CIRCUIT_BREAKER_OPEN,
);
});
});

it('wraps object circuit breaker errors with a localized error key', async () => {
await withController(async ({ controller, rootMessenger }) => {
const fetcher = async (): Promise<string> => {
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw { message: circuitBreakerOpenErrorMessage };
};

await expect(
rootMessenger.call(
'RampsController:executeRequest',
'error-key-circuit-breaker-object',
fetcher,
),
).rejects.toMatchObject({
errorKey: RAMPS_ERROR_CODES.CIRCUIT_BREAKER_OPEN,
message: circuitBreakerOpenErrorMessage,
});

const requestState =
controller.state.requests['error-key-circuit-breaker-object'];
expect(requestState?.status).toBe(RequestStatus.ERROR);
expect(requestState?.error).toBe(circuitBreakerOpenErrorMessage);
expect(requestState?.errorKey).toBe(
RAMPS_ERROR_CODES.CIRCUIT_BREAKER_OPEN,
);
});
});

it('stores fallback error message when error has no message', async () => {
await withController(async ({ controller, rootMessenger }) => {
const fetcher = async (): Promise<string> => {
Expand Down Expand Up @@ -1118,6 +1210,65 @@ describe('RampsController', () => {
});
});

it('stores resource error keys for localized request failures', async () => {
await withController(async ({ controller, rootMessenger }) => {
const fetcher = async (): Promise<string> => {
throw new Error(circuitBreakerOpenErrorMessage);
};

await expect(
rootMessenger.call(
'RampsController:executeRequest',
'providers-circuit-breaker-key',
fetcher,
{ resourceType: 'providers' },
),
).rejects.toMatchObject({
errorKey: RAMPS_ERROR_CODES.CIRCUIT_BREAKER_OPEN,
message: circuitBreakerOpenErrorMessage,
});

expect(controller.state.providers.error).toBe(
circuitBreakerOpenErrorMessage,
);
expect(controller.state.providers.errorKey).toBe(
RAMPS_ERROR_CODES.CIRCUIT_BREAKER_OPEN,
);
});
});

it('clears resource error keys after a successful retry', async () => {
await withController(async ({ controller, rootMessenger }) => {
await expect(
rootMessenger.call(
'RampsController:executeRequest',
'providers-circuit-breaker-key',
async () => {
throw new Error(circuitBreakerOpenErrorMessage);
},
{ resourceType: 'providers' },
),
).rejects.toMatchObject({
errorKey: RAMPS_ERROR_CODES.CIRCUIT_BREAKER_OPEN,
});

expect(controller.state.providers.errorKey).toBe(
RAMPS_ERROR_CODES.CIRCUIT_BREAKER_OPEN,
);

const result = await rootMessenger.call(
'RampsController:executeRequest',
'providers-circuit-breaker-key-success',
async () => 'ok',
{ resourceType: 'providers' },
);

expect(result).toBe('ok');
expect(controller.state.providers.error).toBeNull();
expect(controller.state.providers.errorKey).toBeNull();
});
});

it('sets loading state while request is in progress', async () => {
await withController(async ({ controller, rootMessenger }) => {
let resolvePromise: (value: string) => void;
Expand Down Expand Up @@ -2109,6 +2260,24 @@ describe('RampsController', () => {
options: {
state: {
countries: createResourceState(createMockCountries()),
providers: {
...createResourceState(
[mockSelectedProvider],
mockSelectedProvider,
),
error: circuitBreakerOpenErrorMessage,
errorKey: RAMPS_ERROR_CODES.CIRCUIT_BREAKER_OPEN,
},
tokens: {
...createResourceState({ topTokens: [], allTokens: [] }, null),
error: circuitBreakerOpenErrorMessage,
errorKey: RAMPS_ERROR_CODES.CIRCUIT_BREAKER_OPEN,
},
paymentMethods: {
...createResourceState([mockPaymentMethod], mockPaymentMethod),
error: circuitBreakerOpenErrorMessage,
errorKey: RAMPS_ERROR_CODES.CIRCUIT_BREAKER_OPEN,
},
},
},
},
Expand Down Expand Up @@ -2163,8 +2332,11 @@ describe('RampsController', () => {
// Region change resets dependent resources
expect(controller.state.tokens.data).toBeNull();
expect(controller.state.providers.data).toStrictEqual([]);
expect(controller.state.providers.errorKey).toBeNull();
expect(controller.state.paymentMethods.data).toStrictEqual([]);
expect(controller.state.paymentMethods.selected).toBeNull();
expect(controller.state.paymentMethods.errorKey).toBeNull();
expect(controller.state.tokens.errorKey).toBeNull();
},
);
});
Expand Down Expand Up @@ -7313,6 +7485,41 @@ describe('RampsController', () => {
});
});

it('tags circuit breaker errors with a localized error key', async () => {
await withController(async ({ controller, rootMessenger }) => {
const error = new Error(circuitBreakerOpenErrorMessage);
rootMessenger.registerActionHandler(
'TransakService:getBuyQuote',
async () => {
throw error;
},
);
await expect(
rootMessenger.call(
'RampsController:transakGetBuyQuote',
'USD',
'BTC',
'bitcoin',
'credit_debit_card',
'100',
),
).rejects.toMatchObject({
errorKey: RAMPS_ERROR_CODES.CIRCUIT_BREAKER_OPEN,
message: circuitBreakerOpenErrorMessage,
});
expect(controller.state.nativeProviders.transak.buyQuote.error).toBe(
circuitBreakerOpenErrorMessage,
);
expect(
controller.state.nativeProviders.transak.buyQuote.errorKey,
).toBe(RAMPS_ERROR_CODES.CIRCUIT_BREAKER_OPEN);
expect(error.message).toBe(circuitBreakerOpenErrorMessage);
expect((error as Error & { errorKey?: string }).errorKey).toBe(
RAMPS_ERROR_CODES.CIRCUIT_BREAKER_OPEN,
);
});
});

it('uses fallback error message when error has no message', async () => {
await withController(async ({ controller, rootMessenger }) => {
rootMessenger.registerActionHandler(
Expand Down
Loading
Loading