refactor(api): normalize app errors and simplify ui error reason handling (#1102)

* refactor(api): normalize errors at api boundary

* refactor(error): simplify getErrorReason to normalized fields

* refactor(import): use shared getErrorReason helper

---------

Co-authored-by: Saurabh Singh <saurabhsraghuvanshi@gmail.com>
This commit is contained in:
Parth 2026-02-17 19:10:24 +05:30 committed by GitHub
parent d1cabba166
commit fff35593e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 70 additions and 94 deletions

View file

@ -21,6 +21,7 @@ import { MAX_WALLET_NAME_LENGTH } from '@/constants/jam'
import { JM_DEFAULT_WALLET_TYPE, JM_WALLET_FILE_EXTENSION } from '@/constants/jm'
import { routes } from '@/constants/routes'
import { useApiClient } from '@/hooks/useApiClient'
import { getErrorReason } from '@/lib/errorReason'
import { hashPassword } from '@/lib/hash'
import { withQueryDelay } from '@/lib/queryClient'
import { walletDisplayNameToFileName } from '@/lib/utils'
@ -38,28 +39,6 @@ interface ImportWalletFormValues {
seedPhrase: string
}
const getImportErrorReason = (error: unknown, fallback: string) => {
if (error instanceof Error && error.message) return error.message
if (typeof error === 'string' && error.trim()) return error
if (error && typeof error === 'object') {
const maybeError = error as {
message?: unknown
error_description?: unknown
detail?: unknown
}
if (typeof maybeError.error_description === 'string' && maybeError.error_description.trim()) {
return maybeError.error_description
}
if (typeof maybeError.message === 'string' && maybeError.message.trim()) {
return maybeError.message
}
if (typeof maybeError.detail === 'string' && maybeError.detail.trim()) {
return maybeError.detail
}
}
return fallback
}
const normalizeSeedPhrase = (value?: string) =>
(value ?? '')
.toLowerCase()
@ -174,7 +153,7 @@ const ImportWalletPage = () => {
toast.success(t('import_wallet.success.title'))
await navigate(routes.home)
} catch (error: unknown) {
const reason = getImportErrorReason(error, t('global.errors.reason_unknown'))
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
toast.error(t('import_wallet.error_importing_failed', { reason }))
}
}

View file

@ -2,6 +2,7 @@ import { createClient } from '@joinmarket-webui/joinmarket-api-ts'
import type { Client } from '@joinmarket-webui/joinmarket-api-ts/client'
import type { ClientOptions, UnlockWalletResponse } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { isDevMode } from '@/constants/debugFeatures'
import { normalizeAppError } from '@/lib/errorReason'
import { authStore } from '@/store/authStore'
type ApiToken = UnlockWalletResponse['token']
@ -19,6 +20,10 @@ function loggingResponseInterceptor(response: Response) {
return response
}
function normalizeErrorInterceptor(error: unknown) {
return normalizeAppError(error)
}
const createJamAuthenticationMiddleware = () => {
// eslint-disable-next-line unicorn/consistent-function-scoping -- false positive
return (request: Request) => {
@ -40,6 +45,7 @@ export const createApiClient = (): Client => {
const jamAuthMiddleware = createJamAuthenticationMiddleware()
client.interceptors.request.use(jamAuthMiddleware)
client.interceptors.error.use(normalizeErrorInterceptor)
if (isDevMode()) {
client.interceptors.request.use(loggingRequestInterceptor)

View file

@ -1,5 +1,36 @@
import { describe, it, expect } from 'vitest'
import { getErrorReason } from './errorReason'
import { getErrorReason, normalizeAppError } from './errorReason'
describe('normalizeAppError', () => {
it('normalizes direct string errors', () => {
expect(normalizeAppError('backend exploded')).toEqual({ message: 'backend exploded' })
})
it('normalizes Error instances', () => {
expect(normalizeAppError(new Error('request failed'))).toEqual({ message: 'request failed' })
})
it('keeps message and error_description when present', () => {
const error = {
message: 'Request failed with status 400',
error_description: 'Wallet is already unlocked',
}
expect(normalizeAppError(error)).toEqual({
message: 'Request failed with status 400',
error_description: 'Wallet is already unlocked',
})
})
it('uses error_description as message when message is missing', () => {
const error = {
error_description: 'Invalid wallet password',
}
expect(normalizeAppError(error)).toEqual({
message: 'Invalid wallet password',
error_description: 'Invalid wallet password',
})
})
})
describe('getErrorReason', () => {
it('returns fallback when nothing usable is present', () => {
@ -20,15 +51,14 @@ describe('getErrorReason', () => {
expect(getErrorReason(error, 'fallback')).toBe('Wallet is already unlocked')
})
it('prefers detail over message when error_description is missing', () => {
it('returns message when error_description is missing', () => {
const error = {
message: 'Request failed with status 422',
detail: 'Seed phrase checksum failed',
}
expect(getErrorReason(error, 'fallback')).toBe('Seed phrase checksum failed')
expect(getErrorReason(error, 'fallback')).toBe('Request failed with status 422')
})
it('extracts nested backend reason from response.data', () => {
it('returns fallback for non-normalized nested objects', () => {
const error = {
response: {
data: {
@ -36,15 +66,6 @@ describe('getErrorReason', () => {
},
},
}
expect(getErrorReason(error, 'fallback')).toBe('Invalid wallet password')
})
it('extracts nested backend reason from error payload', () => {
const error = {
error: {
detail: 'Coinjoin is currently in progress',
},
}
expect(getErrorReason(error, 'fallback')).toBe('Coinjoin is currently in progress')
expect(getErrorReason(error, 'fallback')).toBe('fallback')
})
})

View file

@ -2,71 +2,41 @@ const toNonEmptyString = (value: unknown): string | undefined => {
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
const extractReason = (error: unknown, depth = 0): string | undefined => {
if (depth > 4 || error === null || error === undefined) {
return undefined
export interface AppError {
message: string
error_description?: string
}
export const normalizeAppError = (error: unknown): AppError => {
const asString = toNonEmptyString(error)
if (asString) {
return { message: asString }
}
const directString = toNonEmptyString(error)
if (directString) {
return directString
if (error instanceof Error) {
return { message: error.message.trim() }
}
if (typeof error !== 'object') {
return undefined
}
if (typeof error === 'object' && error !== null) {
const maybeError = error as { message?: unknown; error_description?: unknown }
const message = toNonEmptyString(maybeError.message)
const errorDescription = toNonEmptyString(maybeError.error_description)
const maybeError = error as {
message?: unknown
error_description?: unknown
detail?: unknown
statusText?: unknown
title?: unknown
error?: unknown
response?: { data?: unknown } | unknown
data?: unknown
body?: unknown
cause?: unknown
}
const prioritizedReason = [
maybeError.error_description,
maybeError.detail,
maybeError.message,
maybeError.statusText,
maybeError.title,
]
.map((value) => toNonEmptyString(value))
.find(Boolean)
if (prioritizedReason) {
return prioritizedReason
}
const responseData =
maybeError.response && typeof maybeError.response === 'object'
? (maybeError.response as { data?: unknown }).data
: undefined
const nestedCandidates = [
maybeError.error,
maybeError.response,
responseData,
maybeError.data,
maybeError.body,
maybeError.cause,
]
for (const candidate of nestedCandidates) {
const nestedReason = extractReason(candidate, depth + 1)
if (nestedReason) {
return nestedReason
if (message && errorDescription) {
return { message, error_description: errorDescription }
}
if (message) {
return { message }
}
if (errorDescription) {
return { message: errorDescription, error_description: errorDescription }
}
}
return undefined
return { message: '' }
}
export const getErrorReason = (error: unknown, fallback: string): string => {
return extractReason(error) ?? fallback
const normalized = normalizeAppError(error)
return toNonEmptyString(normalized.error_description) ?? toNonEmptyString(normalized.message) ?? fallback
}