mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-16 13:01:05 +02:00
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:
parent
d1cabba166
commit
fff35593e7
4 changed files with 70 additions and 94 deletions
|
|
@ -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 }))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue