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..a313c0d975e 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 + +- 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 diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index da95ff7c423..741682d0523 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,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'); @@ -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 => { + 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 => { + // 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 => { + // 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 => { @@ -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 => { + 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; @@ -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, + }, }, }, }, @@ -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(); }, ); }); @@ -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( diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index b50432444ee..f1425629d9e 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -9,6 +9,8 @@ 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 { RAMPS_ERROR_CODES } from './rampsErrorCodes'; import type { BuyWidget, Country, @@ -156,6 +158,87 @@ 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'; + +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' && + 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 getRampsErrorInfo(error: unknown): RampsErrorInfo { + 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 { + errorKey: RAMPS_ERROR_CODES.CIRCUIT_BREAKER_OPEN, + message: rawMessage, + }; + } + + return { + errorKey: null, + message: rawMessage ?? 'Unknown error', + }; +} + +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') { + return Object.assign(new Error(errorInfo.message), { + errorKey: errorInfo.errorKey, + }); + } + + return Object.assign(new Error(errorInfo.message), { + errorKey: errorInfo.errorKey, + }); +} + // === STATE === /** @@ -199,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; }; /** @@ -406,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; } /** @@ -902,19 +1009,24 @@ export class RampsController extends BaseController< throw error; } - const errorMessage = (error as Error)?.message ?? 'Unknown 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 error; + throw normalizedError; } finally { if ( this.#pendingRequests.get(cacheKey)?.abortController === @@ -1034,14 +1146,12 @@ 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]; - if (resource) { - (resource as Record)[field] = value; - } + const resource = getResourceState(state, resourceType); + (resource as Record)[field] = value; }); } @@ -1059,10 +1169,17 @@ 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 = getResourceState(state, resourceType); + resource.error = errorInfo?.message ?? null; + resource.errorKey = errorInfo?.errorKey ?? null; + }); } /** @@ -2052,11 +2169,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); } } @@ -2177,6 +2290,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( @@ -2189,12 +2303,14 @@ export class RampsController extends BaseController< return details; } catch (error) { this.#syncTransakAuthOnError(error); - const errorMessage = (error as Error)?.message ?? 'Unknown 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 error; + throw normalizedError; } } @@ -2219,6 +2335,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( @@ -2235,12 +2352,14 @@ export class RampsController extends BaseController< }); return quote; } catch (error) { - const errorMessage = (error as Error)?.message ?? 'Unknown 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 error; + throw normalizedError; } } @@ -2257,6 +2376,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( @@ -2270,12 +2390,15 @@ export class RampsController extends BaseController< return requirement; } catch (error) { this.#syncTransakAuthOnError(error); - const errorMessage = (error as Error)?.message ?? 'Unknown 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 error; + 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..86a9883d7e6 100644 --- a/packages/ramps-controller/src/selectors.test.ts +++ b/packages/ramps-controller/src/selectors.test.ts @@ -1,4 +1,5 @@ import type { RampsControllerState } from './RampsController'; +import { RAMPS_ERROR_CODES } from './rampsErrorCodes'; import { createLoadingState, createSuccessState, @@ -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..fd8cc6a9fd6 100644 --- a/packages/ramps-controller/src/selectors.ts +++ b/packages/ramps-controller/src/selectors.ts @@ -1,4 +1,5 @@ import type { RampsControllerState } from './RampsController'; +import type { RampsErrorCode } from './rampsErrorCodes'; import type { RequestState } from './RequestCache'; import { RequestStatus, createCacheKey } from './RequestCache'; @@ -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; }; }