From 793edd79b9b33a33ffba60285cb5be010003c55a Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Mon, 27 Apr 2026 17:21:33 +0300 Subject: [PATCH 1/3] fix(ramps-controller): replace circuit breaker error copy --- eslint-suppressions.json | 5 -- packages/ramps-controller/CHANGELOG.md | 4 + .../src/RampsController.test.ts | 78 +++++++++++++++++ .../ramps-controller/src/RampsController.ts | 87 ++++++++++++++++--- 4 files changed, 156 insertions(+), 18 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 793f8d99be9..d7f967d1418 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index c11179a8c36..11e4b899b9b 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Normalize circuit-breaker errors in `RampsController` to a user-facing retry message so consumers do not surface the internal Cockatiel error text to users. + ## [13.2.0] ### Changed diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index da95ff7c423..7aa7e67bc94 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -61,6 +61,9 @@ import type { } from './TransakService'; describe('RampsController', () => { + const circuitBreakerOpenUserMessage = + "We're having trouble connecting right now. Please try again in a few minutes."; + describe('normalizeProviderCode', () => { it('strips /providers/ prefix', () => { expect(normalizeProviderCode('/providers/transak')).toBe('transak'); @@ -1075,6 +1078,53 @@ describe('RampsController', () => { }); }); + it('maps circuit breaker errors to a user-facing message', async () => { + await withController(async ({ controller, rootMessenger }) => { + const error = new Error( + 'Execution prevented because the circuit breaker is open', + ); + const fetcher = async (): Promise => { + throw error; + }; + + await expect( + rootMessenger.call( + 'RampsController:executeRequest', + 'error-key-circuit-breaker', + fetcher, + ), + ).rejects.toThrow(circuitBreakerOpenUserMessage); + + const requestState = + controller.state.requests['error-key-circuit-breaker']; + expect(requestState?.status).toBe(RequestStatus.ERROR); + expect(requestState?.error).toBe(circuitBreakerOpenUserMessage); + expect(error.message).toBe(circuitBreakerOpenUserMessage); + }); + }); + + it('wraps string circuit breaker errors with a user-facing message', async () => { + await withController(async ({ controller, rootMessenger }) => { + const fetcher = async (): Promise => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 'Execution prevented because the circuit breaker is open'; + }; + + await expect( + rootMessenger.call( + 'RampsController:executeRequest', + 'error-key-circuit-breaker-string', + fetcher, + ), + ).rejects.toThrow(circuitBreakerOpenUserMessage); + + const requestState = + controller.state.requests['error-key-circuit-breaker-string']; + expect(requestState?.status).toBe(RequestStatus.ERROR); + expect(requestState?.error).toBe(circuitBreakerOpenUserMessage); + }); + }); + it('stores fallback error message when error has no message', async () => { await withController(async ({ controller, rootMessenger }) => { const fetcher = async (): Promise => { @@ -7313,6 +7363,34 @@ describe('RampsController', () => { }); }); + it('maps circuit breaker errors to a user-facing message', async () => { + await withController(async ({ controller, rootMessenger }) => { + const error = new Error( + 'Execution prevented because the circuit breaker is open', + ); + rootMessenger.registerActionHandler( + 'TransakService:getBuyQuote', + async () => { + throw error; + }, + ); + await expect( + rootMessenger.call( + 'RampsController:transakGetBuyQuote', + 'USD', + 'BTC', + 'bitcoin', + 'credit_debit_card', + '100', + ), + ).rejects.toThrow(circuitBreakerOpenUserMessage); + expect(controller.state.nativeProviders.transak.buyQuote.error).toBe( + circuitBreakerOpenUserMessage, + ); + expect(error.message).toBe(circuitBreakerOpenUserMessage); + }); + }); + it('uses fallback error message when error has no message', async () => { await withController(async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index b50432444ee..b4b7a3c1e3a 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -156,6 +156,67 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: readonly ( */ const DEFAULT_QUOTES_TTL = 15000; +const CIRCUIT_BREAKER_OPEN_ERROR = + 'Execution prevented because the circuit breaker is open'; + +const CIRCUIT_BREAKER_OPEN_USER_MESSAGE = + "We're having trouble connecting right now. Please try again in a few minutes."; + +type ErrorWithMessage = { + message: string; +}; + +type ErrorWithHttpStatus = Error & { + httpStatus: number; +}; + +function hasStringMessage(error: unknown): error is ErrorWithMessage { + return ( + typeof error === 'object' && + error !== null && + typeof (error as { message?: unknown }).message === 'string' + ); +} + +function hasHttpStatus(error: unknown): error is ErrorWithHttpStatus { + return ( + error instanceof Error && + typeof (error as { httpStatus?: unknown }).httpStatus === 'number' + ); +} + +function getRampsErrorMessage(error: unknown): string { + let rawMessage: string | undefined; + + if (hasStringMessage(error)) { + rawMessage = error.message; + } else if (typeof error === 'string') { + rawMessage = error; + } + + if (rawMessage?.includes(CIRCUIT_BREAKER_OPEN_ERROR)) { + return CIRCUIT_BREAKER_OPEN_USER_MESSAGE; + } + + return rawMessage ?? 'Unknown error'; +} + +function normalizeRampsErrorForRethrow(error: unknown): unknown { + if (hasStringMessage(error)) { + if (error.message.includes(CIRCUIT_BREAKER_OPEN_ERROR)) { + error.message = CIRCUIT_BREAKER_OPEN_USER_MESSAGE; + } + + return error; + } + + if (typeof error === 'string' && error.includes(CIRCUIT_BREAKER_OPEN_ERROR)) { + return new Error(CIRCUIT_BREAKER_OPEN_USER_MESSAGE); + } + + return error; +} + // === STATE === /** @@ -902,7 +963,8 @@ export class RampsController extends BaseController< throw error; } - const errorMessage = (error as Error)?.message ?? 'Unknown error'; + const errorMessage = getRampsErrorMessage(error); + const normalizedError = normalizeRampsErrorForRethrow(error); this.#updateRequestState( cacheKey, createErrorState(errorMessage, lastFetchedAt), @@ -914,7 +976,7 @@ export class RampsController extends BaseController< this.#setResourceError(resourceType, errorMessage); } } - throw error; + throw normalizedError; } finally { if ( this.#pendingRequests.get(cacheKey)?.abortController === @@ -2052,11 +2114,7 @@ export class RampsController extends BaseController< * @param error - The caught error to inspect. */ #syncTransakAuthOnError(error: unknown): void { - if ( - error instanceof Error && - 'httpStatus' in error && - (error as Error & { httpStatus: number }).httpStatus === 401 - ) { + if (hasHttpStatus(error) && error.httpStatus === 401) { this.transakSetAuthenticated(false); } } @@ -2189,12 +2247,13 @@ export class RampsController extends BaseController< return details; } catch (error) { this.#syncTransakAuthOnError(error); - const errorMessage = (error as Error)?.message ?? 'Unknown error'; + const errorMessage = getRampsErrorMessage(error); + const normalizedError = normalizeRampsErrorForRethrow(error); this.update((state) => { state.nativeProviders.transak.userDetails.isLoading = false; state.nativeProviders.transak.userDetails.error = errorMessage; }); - throw error; + throw normalizedError; } } @@ -2235,12 +2294,13 @@ export class RampsController extends BaseController< }); return quote; } catch (error) { - const errorMessage = (error as Error)?.message ?? 'Unknown error'; + const errorMessage = getRampsErrorMessage(error); + const normalizedError = normalizeRampsErrorForRethrow(error); this.update((state) => { state.nativeProviders.transak.buyQuote.isLoading = false; state.nativeProviders.transak.buyQuote.error = errorMessage; }); - throw error; + throw normalizedError; } } @@ -2270,12 +2330,13 @@ export class RampsController extends BaseController< return requirement; } catch (error) { this.#syncTransakAuthOnError(error); - const errorMessage = (error as Error)?.message ?? 'Unknown error'; + const errorMessage = getRampsErrorMessage(error); + const normalizedError = normalizeRampsErrorForRethrow(error); this.update((state) => { state.nativeProviders.transak.kycRequirement.isLoading = false; state.nativeProviders.transak.kycRequirement.error = errorMessage; }); - throw error; + throw normalizedError; } } From 5a418d13d29d42fbe859d2ef7f983e3d65d97756 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Mon, 27 Apr 2026 18:29:51 +0300 Subject: [PATCH 2/3] refactor(ramps-controller): expose circuit breaker error key --- packages/ramps-controller/CHANGELOG.md | 2 +- .../src/RampsController.test.ts | 116 ++++++++++++++--- .../ramps-controller/src/RampsController.ts | 118 +++++++++++++----- packages/ramps-controller/src/RequestCache.ts | 14 ++- packages/ramps-controller/src/index.ts | 2 + .../ramps-controller/src/rampsErrorCodes.ts | 10 ++ .../ramps-controller/src/selectors.test.ts | 34 ++++- packages/ramps-controller/src/selectors.ts | 11 +- 8 files changed, 251 insertions(+), 56 deletions(-) create mode 100644 packages/ramps-controller/src/rampsErrorCodes.ts diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 11e4b899b9b..a313c0d975e 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Normalize circuit-breaker errors in `RampsController` to a user-facing retry message so consumers do not surface the internal Cockatiel error text to users. +- 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] diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 7aa7e67bc94..c05649bff7a 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -20,6 +20,7 @@ import { getDefaultRampsControllerState, RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, } from './RampsController'; +import { RAMPS_ERROR_CODES } from './rampsErrorCodes'; import type { Country, TokensResponse, @@ -61,8 +62,8 @@ import type { } from './TransakService'; describe('RampsController', () => { - const circuitBreakerOpenUserMessage = - "We're having trouble connecting right now. Please try again in a few minutes."; + const circuitBreakerOpenErrorMessage = + 'Execution prevented because the circuit breaker is open'; describe('normalizeProviderCode', () => { it('strips /providers/ prefix', () => { @@ -1078,11 +1079,9 @@ describe('RampsController', () => { }); }); - it('maps circuit breaker errors to a user-facing message', async () => { + it('tags circuit breaker errors with a localized error key', async () => { await withController(async ({ controller, rootMessenger }) => { - const error = new Error( - 'Execution prevented because the circuit breaker is open', - ); + const error = new Error(circuitBreakerOpenErrorMessage); const fetcher = async (): Promise => { throw error; }; @@ -1093,21 +1092,30 @@ describe('RampsController', () => { 'error-key-circuit-breaker', fetcher, ), - ).rejects.toThrow(circuitBreakerOpenUserMessage); + ).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(circuitBreakerOpenUserMessage); - expect(error.message).toBe(circuitBreakerOpenUserMessage); + 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 user-facing message', async () => { + it('wraps string circuit breaker errors with a localized error key', async () => { await withController(async ({ controller, rootMessenger }) => { const fetcher = async (): Promise => { // eslint-disable-next-line @typescript-eslint/only-throw-error - throw 'Execution prevented because the circuit breaker is open'; + throw circuitBreakerOpenErrorMessage; }; await expect( @@ -1116,12 +1124,46 @@ describe('RampsController', () => { 'error-key-circuit-breaker-string', fetcher, ), - ).rejects.toThrow(circuitBreakerOpenUserMessage); + ).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(circuitBreakerOpenUserMessage); + 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 => { + // 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, + ); }); }); @@ -1168,6 +1210,33 @@ describe('RampsController', () => { }); }); + it('stores resource error keys for localized request failures', async () => { + await withController(async ({ controller, rootMessenger }) => { + const fetcher = async (): Promise => { + 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('sets loading state while request is in progress', async () => { await withController(async ({ controller, rootMessenger }) => { let resolvePromise: (value: string) => void; @@ -7363,11 +7432,9 @@ describe('RampsController', () => { }); }); - it('maps circuit breaker errors to a user-facing message', async () => { + it('tags circuit breaker errors with a localized error key', async () => { await withController(async ({ controller, rootMessenger }) => { - const error = new Error( - 'Execution prevented because the circuit breaker is open', - ); + const error = new Error(circuitBreakerOpenErrorMessage); rootMessenger.registerActionHandler( 'TransakService:getBuyQuote', async () => { @@ -7383,11 +7450,20 @@ describe('RampsController', () => { 'credit_debit_card', '100', ), - ).rejects.toThrow(circuitBreakerOpenUserMessage); + ).rejects.toMatchObject({ + errorKey: RAMPS_ERROR_CODES.CIRCUIT_BREAKER_OPEN, + message: circuitBreakerOpenErrorMessage, + }); expect(controller.state.nativeProviders.transak.buyQuote.error).toBe( - circuitBreakerOpenUserMessage, + 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, ); - expect(error.message).toBe(circuitBreakerOpenUserMessage); }); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index b4b7a3c1e3a..eefd9d895e4 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -9,6 +9,7 @@ import type { Json } from '@metamask/utils'; import type { Draft } from 'immer'; import type { RampsControllerMethodActions } from './RampsController-method-action-types'; +import type { RampsErrorCode } from './rampsErrorCodes'; import type { BuyWidget, Country, @@ -96,6 +97,7 @@ import type { TransakServiceCancelAllActiveOrdersAction, TransakServiceGetActiveOrdersAction, } from './TransakService-method-action-types'; +import { RAMPS_ERROR_CODES } from './rampsErrorCodes'; // === GENERAL === @@ -159,17 +161,23 @@ const DEFAULT_QUOTES_TTL = 15000; const CIRCUIT_BREAKER_OPEN_ERROR = 'Execution prevented because the circuit breaker is open'; -const CIRCUIT_BREAKER_OPEN_USER_MESSAGE = - "We're having trouble connecting right now. Please try again in a few minutes."; - type ErrorWithMessage = { message: string; }; +type ErrorWithRampsErrorKey = Error & { + errorKey?: RampsErrorCode; +}; + type ErrorWithHttpStatus = Error & { httpStatus: number; }; +type RampsErrorInfo = { + errorKey: RampsErrorCode | null; + message: string; +}; + function hasStringMessage(error: unknown): error is ErrorWithMessage { return ( typeof error === 'object' && @@ -185,7 +193,7 @@ function hasHttpStatus(error: unknown): error is ErrorWithHttpStatus { ); } -function getRampsErrorMessage(error: unknown): string { +function getRampsErrorInfo(error: unknown): RampsErrorInfo { let rawMessage: string | undefined; if (hasStringMessage(error)) { @@ -195,26 +203,40 @@ function getRampsErrorMessage(error: unknown): string { } if (rawMessage?.includes(CIRCUIT_BREAKER_OPEN_ERROR)) { - return CIRCUIT_BREAKER_OPEN_USER_MESSAGE; + return { + errorKey: RAMPS_ERROR_CODES.CIRCUIT_BREAKER_OPEN, + message: rawMessage, + }; } - return rawMessage ?? 'Unknown error'; + return { + errorKey: null, + message: rawMessage ?? 'Unknown error', + }; } -function normalizeRampsErrorForRethrow(error: unknown): unknown { - if (hasStringMessage(error)) { - if (error.message.includes(CIRCUIT_BREAKER_OPEN_ERROR)) { - error.message = CIRCUIT_BREAKER_OPEN_USER_MESSAGE; - } +function normalizeRampsErrorForRethrow( + error: unknown, + errorInfo: RampsErrorInfo, +): unknown { + if (!errorInfo.errorKey) { + return error; + } + if (error instanceof Error) { + (error as ErrorWithRampsErrorKey).errorKey = errorInfo.errorKey; return error; } - if (typeof error === 'string' && error.includes(CIRCUIT_BREAKER_OPEN_ERROR)) { - return new Error(CIRCUIT_BREAKER_OPEN_USER_MESSAGE); + if (typeof error === 'string') { + return Object.assign(new Error(errorInfo.message), { + errorKey: errorInfo.errorKey, + }); } - return error; + return Object.assign(new Error(errorInfo.message), { + errorKey: errorInfo.errorKey, + }); } // === STATE === @@ -260,6 +282,10 @@ export type ResourceState = { * Error message if the fetch failed, or null. */ error: string | null; + /** + * Stable error key for client-side localization, if available. + */ + errorKey?: RampsErrorCode | null; }; /** @@ -963,17 +989,21 @@ export class RampsController extends BaseController< throw error; } - const errorMessage = getRampsErrorMessage(error); - const normalizedError = normalizeRampsErrorForRethrow(error); + const errorInfo = getRampsErrorInfo(error); + const normalizedError = normalizeRampsErrorForRethrow(error, errorInfo); this.#updateRequestState( cacheKey, - createErrorState(errorMessage, lastFetchedAt), + createErrorState( + errorInfo.message, + lastFetchedAt, + errorInfo.errorKey, + ), ); if (resourceType) { const isCurrent = !options?.isResultCurrent || options.isResultCurrent(); if (isCurrent) { - this.#setResourceError(resourceType, errorMessage); + this.#setResourceError(resourceType, errorInfo); } } throw normalizedError; @@ -1096,8 +1126,8 @@ export class RampsController extends BaseController< */ #updateResourceField( resourceType: ResourceType, - field: 'isLoading' | 'error', - value: boolean | string | null, + field: 'isLoading' | 'error' | 'errorKey', + value: boolean | string | RampsErrorCode | null, ): void { this.update((state) => { const resource = state[resourceType]; @@ -1121,10 +1151,26 @@ export class RampsController extends BaseController< * Sets the error state for a resource type. * * @param resourceType - The type of resource. - * @param error - The error message, or null to clear. + * @param errorInfo - The error info, or null to clear. */ - #setResourceError(resourceType: ResourceType, error: string | null): void { - this.#updateResourceField(resourceType, 'error', error); + #setResourceError( + resourceType: ResourceType, + errorInfo: RampsErrorInfo | null, + ): void { + this.update((state) => { + const resource = state[resourceType]; + if (!resource) { + return; + } + + resource.error = errorInfo?.message ?? null; + + if (errorInfo?.errorKey) { + resource.errorKey = errorInfo.errorKey; + } else { + delete resource.errorKey; + } + }); } /** @@ -2235,6 +2281,7 @@ export class RampsController extends BaseController< this.update((state) => { state.nativeProviders.transak.userDetails.isLoading = true; state.nativeProviders.transak.userDetails.error = null; + delete state.nativeProviders.transak.userDetails.errorKey; }); try { const details = await this.messenger.call( @@ -2247,11 +2294,13 @@ export class RampsController extends BaseController< return details; } catch (error) { this.#syncTransakAuthOnError(error); - const errorMessage = getRampsErrorMessage(error); - const normalizedError = normalizeRampsErrorForRethrow(error); + const errorInfo = getRampsErrorInfo(error); + const normalizedError = normalizeRampsErrorForRethrow(error, errorInfo); this.update((state) => { state.nativeProviders.transak.userDetails.isLoading = false; - state.nativeProviders.transak.userDetails.error = errorMessage; + state.nativeProviders.transak.userDetails.error = errorInfo.message; + state.nativeProviders.transak.userDetails.errorKey = + errorInfo.errorKey; }); throw normalizedError; } @@ -2278,6 +2327,7 @@ export class RampsController extends BaseController< this.update((state) => { state.nativeProviders.transak.buyQuote.isLoading = true; state.nativeProviders.transak.buyQuote.error = null; + delete state.nativeProviders.transak.buyQuote.errorKey; }); try { const quote = await this.messenger.call( @@ -2294,11 +2344,12 @@ export class RampsController extends BaseController< }); return quote; } catch (error) { - const errorMessage = getRampsErrorMessage(error); - const normalizedError = normalizeRampsErrorForRethrow(error); + const errorInfo = getRampsErrorInfo(error); + const normalizedError = normalizeRampsErrorForRethrow(error, errorInfo); this.update((state) => { state.nativeProviders.transak.buyQuote.isLoading = false; - state.nativeProviders.transak.buyQuote.error = errorMessage; + state.nativeProviders.transak.buyQuote.error = errorInfo.message; + state.nativeProviders.transak.buyQuote.errorKey = errorInfo.errorKey; }); throw normalizedError; } @@ -2317,6 +2368,7 @@ export class RampsController extends BaseController< this.update((state) => { state.nativeProviders.transak.kycRequirement.isLoading = true; state.nativeProviders.transak.kycRequirement.error = null; + delete state.nativeProviders.transak.kycRequirement.errorKey; }); try { const requirement = await this.messenger.call( @@ -2330,11 +2382,13 @@ export class RampsController extends BaseController< return requirement; } catch (error) { this.#syncTransakAuthOnError(error); - const errorMessage = getRampsErrorMessage(error); - const normalizedError = normalizeRampsErrorForRethrow(error); + const errorInfo = getRampsErrorInfo(error); + const normalizedError = normalizeRampsErrorForRethrow(error, errorInfo); this.update((state) => { state.nativeProviders.transak.kycRequirement.isLoading = false; - state.nativeProviders.transak.kycRequirement.error = errorMessage; + state.nativeProviders.transak.kycRequirement.error = errorInfo.message; + state.nativeProviders.transak.kycRequirement.errorKey = + errorInfo.errorKey; }); throw normalizedError; } diff --git a/packages/ramps-controller/src/RequestCache.ts b/packages/ramps-controller/src/RequestCache.ts index 4e50b4c3c8f..7cfc8e1e7e4 100644 --- a/packages/ramps-controller/src/RequestCache.ts +++ b/packages/ramps-controller/src/RequestCache.ts @@ -1,5 +1,7 @@ import type { Json } from '@metamask/utils'; +import type { RampsErrorCode } from './rampsErrorCodes'; + /** * Types of resources that can have loading/error states. */ @@ -30,6 +32,8 @@ export type RequestState = { data: Json | null; /** Error message if the request failed */ error: string | null; + /** Stable error key for client-side localization, if available */ + errorKey?: RampsErrorCode | null; /** Timestamp when the request completed (for TTL calculation) */ timestamp: number; /** Timestamp when the fetch started */ @@ -121,19 +125,27 @@ export function createSuccessState( * * @param error - The error message. * @param lastFetchedAt - When the fetch started. + * @param errorKey - Stable error key for client-side localization, if available. * @returns A new RequestState in error status. */ export function createErrorState( error: string, lastFetchedAt: number, + errorKey?: RampsErrorCode | null, ): RequestState { - return { + const requestState: RequestState = { status: RequestStatus.ERROR, data: null, error, timestamp: Date.now(), lastFetchedAt, }; + + if (errorKey) { + requestState.errorKey = errorKey; + } + + return requestState; } /** diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 2cf92ba54d7..c1ec4d850c4 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -126,6 +126,7 @@ export type { PendingRequest, ResourceType, } from './RequestCache'; +export type { RampsErrorCode } from './rampsErrorCodes'; export { RequestStatus, DEFAULT_REQUEST_CACHE_TTL, @@ -136,6 +137,7 @@ export { createSuccessState, createErrorState, } from './RequestCache'; +export { RAMPS_ERROR_CODES } from './rampsErrorCodes'; export type { RequestSelectorResult } from './selectors'; export { createRequestSelector } from './selectors'; export type { diff --git a/packages/ramps-controller/src/rampsErrorCodes.ts b/packages/ramps-controller/src/rampsErrorCodes.ts new file mode 100644 index 00000000000..2112f8c927f --- /dev/null +++ b/packages/ramps-controller/src/rampsErrorCodes.ts @@ -0,0 +1,10 @@ +/** + * Error codes for RampsController. + * These codes are returned to the UI layer for translation. + */ +export const RAMPS_ERROR_CODES = { + CIRCUIT_BREAKER_OPEN: 'CIRCUIT_BREAKER_OPEN', +} as const; + +export type RampsErrorCode = + (typeof RAMPS_ERROR_CODES)[keyof typeof RAMPS_ERROR_CODES]; diff --git a/packages/ramps-controller/src/selectors.test.ts b/packages/ramps-controller/src/selectors.test.ts index 459ad64dedf..678320dbae6 100644 --- a/packages/ramps-controller/src/selectors.test.ts +++ b/packages/ramps-controller/src/selectors.test.ts @@ -4,6 +4,7 @@ import { createSuccessState, createErrorState, } from './RequestCache'; +import { RAMPS_ERROR_CODES } from './rampsErrorCodes'; import { createRequestSelector } from './selectors'; type TestRootState = { @@ -36,7 +37,6 @@ function createMockRampsState( providers: createDefaultResourceState([], null), tokens: createDefaultResourceState(null, null), paymentMethods: createDefaultResourceState([], null), - quotes: createDefaultResourceState(null), requests: {}, ...overrides, }; @@ -130,6 +130,38 @@ describe('createRequestSelector', () => { `); }); + it('includes errorKey when request state is classified for localization', () => { + const selector = createRequestSelector( + getState, + 'getCryptoCurrencies', + ['US'], + ); + + const errorRequest = createErrorState( + 'Execution prevented because the circuit breaker is open', + Date.now(), + RAMPS_ERROR_CODES.CIRCUIT_BREAKER_OPEN, + ); + const state: TestRootState = { + ramps: createMockRampsState({ + requests: { + 'getCryptoCurrencies:["US"]': errorRequest, + }, + }), + }; + + const result = selector(state); + + expect(result).toMatchInlineSnapshot(` + { + "data": null, + "error": "Execution prevented because the circuit breaker is open", + "errorKey": "CIRCUIT_BREAKER_OPEN", + "isFetching": false, + } + `); + }); + it('returns null data when request is missing', () => { const selector = createRequestSelector( getState, diff --git a/packages/ramps-controller/src/selectors.ts b/packages/ramps-controller/src/selectors.ts index a616f9bf625..dae163b4f53 100644 --- a/packages/ramps-controller/src/selectors.ts +++ b/packages/ramps-controller/src/selectors.ts @@ -1,6 +1,7 @@ import type { RampsControllerState } from './RampsController'; import type { RequestState } from './RequestCache'; import { RequestStatus, createCacheKey } from './RequestCache'; +import type { RampsErrorCode } from './rampsErrorCodes'; /** * Result shape returned by request selectors. @@ -16,6 +17,8 @@ export type RequestSelectorResult = { isFetching: boolean; /** Error message if the request failed, or null if successful or not yet attempted. */ error: string | null; + /** Stable error key for client-side localization, if available. */ + errorKey?: RampsErrorCode | null; }; /** @@ -93,12 +96,18 @@ export function createRequestSelector( } lastRequest = request; - lastResult = { + const nextResult: RequestSelectorResult = { data: (request?.data as TData) ?? null, isFetching: request?.status === RequestStatus.LOADING, error: request?.error ?? null, }; + if (request?.errorKey) { + nextResult.errorKey = request.errorKey; + } + + lastResult = nextResult; + return lastResult; }; } From 3a59da9cc57515b5679e568f7f76b88a4742d575 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Mon, 27 Apr 2026 19:11:17 +0300 Subject: [PATCH 3/3] fix(ramps-controller): address PR review issues --- .../src/RampsController.test.ts | 53 +++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 46 +++++++++------- .../ramps-controller/src/selectors.test.ts | 2 +- packages/ramps-controller/src/selectors.ts | 2 +- 4 files changed, 82 insertions(+), 21 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index c05649bff7a..741682d0523 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -1237,6 +1237,38 @@ describe('RampsController', () => { }); }); + 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; @@ -2228,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, + }, }, }, }, @@ -2282,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(); }, ); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index eefd9d895e4..f1425629d9e 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -10,6 +10,7 @@ import type { Draft } from 'immer'; import type { RampsControllerMethodActions } from './RampsController-method-action-types'; import type { RampsErrorCode } from './rampsErrorCodes'; +import { RAMPS_ERROR_CODES } from './rampsErrorCodes'; import type { BuyWidget, Country, @@ -97,7 +98,6 @@ import type { TransakServiceCancelAllActiveOrdersAction, TransakServiceGetActiveOrdersAction, } from './TransakService-method-action-types'; -import { RAMPS_ERROR_CODES } from './rampsErrorCodes'; // === GENERAL === @@ -493,16 +493,36 @@ type DependentResourceKey = (typeof DEPENDENT_RESOURCE_KEYS)[number]; const DEPENDENT_RESOURCE_KEYS_SET = new Set(DEPENDENT_RESOURCE_KEYS); +function getResourceState( + state: Draft, + resourceType: TResourceType, +): Draft { + switch (resourceType) { + case 'countries': + return state.countries as Draft; + case 'providers': + return state.providers as Draft; + case 'tokens': + return state.tokens as Draft; + case 'paymentMethods': + return state.paymentMethods as Draft; + /* istanbul ignore next -- ResourceType is a closed internal union. */ + default: + throw new Error(`Unsupported resource type: ${resourceType as string}`); + } +} + function resetResource( state: Draft, resourceType: DependentResourceKey, defaultResource: RampsControllerState[DependentResourceKey], ): void { - const resource = state[resourceType]; + const resource = getResourceState(state, resourceType); resource.data = defaultResource.data; resource.selected = defaultResource.selected; resource.isLoading = defaultResource.isLoading; resource.error = defaultResource.error; + resource.errorKey = defaultResource.errorKey ?? null; } /** @@ -1130,10 +1150,8 @@ export class RampsController extends BaseController< value: boolean | string | RampsErrorCode | null, ): void { this.update((state) => { - const resource = state[resourceType]; - if (resource) { - (resource as Record)[field] = value; - } + const resource = getResourceState(state, resourceType); + (resource as Record)[field] = value; }); } @@ -1158,18 +1176,9 @@ export class RampsController extends BaseController< errorInfo: RampsErrorInfo | null, ): void { this.update((state) => { - const resource = state[resourceType]; - if (!resource) { - return; - } - + const resource = getResourceState(state, resourceType); resource.error = errorInfo?.message ?? null; - - if (errorInfo?.errorKey) { - resource.errorKey = errorInfo.errorKey; - } else { - delete resource.errorKey; - } + resource.errorKey = errorInfo?.errorKey ?? null; }); } @@ -2299,8 +2308,7 @@ export class RampsController extends BaseController< this.update((state) => { state.nativeProviders.transak.userDetails.isLoading = false; state.nativeProviders.transak.userDetails.error = errorInfo.message; - state.nativeProviders.transak.userDetails.errorKey = - errorInfo.errorKey; + state.nativeProviders.transak.userDetails.errorKey = errorInfo.errorKey; }); throw normalizedError; } diff --git a/packages/ramps-controller/src/selectors.test.ts b/packages/ramps-controller/src/selectors.test.ts index 678320dbae6..86a9883d7e6 100644 --- a/packages/ramps-controller/src/selectors.test.ts +++ b/packages/ramps-controller/src/selectors.test.ts @@ -1,10 +1,10 @@ import type { RampsControllerState } from './RampsController'; +import { RAMPS_ERROR_CODES } from './rampsErrorCodes'; import { createLoadingState, createSuccessState, createErrorState, } from './RequestCache'; -import { RAMPS_ERROR_CODES } from './rampsErrorCodes'; import { createRequestSelector } from './selectors'; type TestRootState = { diff --git a/packages/ramps-controller/src/selectors.ts b/packages/ramps-controller/src/selectors.ts index dae163b4f53..fd8cc6a9fd6 100644 --- a/packages/ramps-controller/src/selectors.ts +++ b/packages/ramps-controller/src/selectors.ts @@ -1,7 +1,7 @@ import type { RampsControllerState } from './RampsController'; +import type { RampsErrorCode } from './rampsErrorCodes'; import type { RequestState } from './RequestCache'; import { RequestStatus, createCacheKey } from './RequestCache'; -import type { RampsErrorCode } from './rampsErrorCodes'; /** * Result shape returned by request selectors.