Skip to content
Merged
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
96 changes: 96 additions & 0 deletions src/lib/__tests__/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest'
import { mapApiError, ERROR_MESSAGES } from '../errors'

describe('mapApiError', () => {
it('reads a backend errorCode from the response envelope (acceptance #1)', () => {
const result = mapApiError({ response: { status: 409, data: { errorCode: 'ROLE_ALREADY_SET' } } })
expect(result).toEqual({
code: 'ROLE_ALREADY_SET',
status: 409,
userMessage: ERROR_MESSAGES.ROLE_ALREADY_SET,
retryable: false,
raw: expect.anything(),
})
})

it('falls back to data.code when errorCode is absent', () => {
const result = mapApiError({ response: { status: 400, data: { code: 'VALIDATION' } } })
expect(result.code).toBe('VALIDATION')
expect(result.userMessage).toBe(ERROR_MESSAGES.VALIDATION)
})

it('resolves every entry in the message table', () => {
for (const code of Object.keys(ERROR_MESSAGES)) {
if (code === 'DEFAULT') continue
const result = mapApiError({ response: { status: 400, data: { errorCode: code } } })
expect(result.code).toBe(code)
expect(result.userMessage).toBe(ERROR_MESSAGES[code])
}
})

it('falls back to DEFAULT for an unknown code (never throws)', () => {
const result = mapApiError({ response: { status: 418, data: { errorCode: 'A_BRAND_NEW_CODE' } } })
expect(result.code).toBe('A_BRAND_NEW_CODE')
expect(result.userMessage).toBe(ERROR_MESSAGES.DEFAULT)
})

it('derives HTTP_<status> codes when the body carries no code', () => {
expect(mapApiError({ response: { status: 404, data: {} } }).code).toBe('HTTP_404')
expect(mapApiError({ response: { status: 503, data: {} } }).code).toBe('HTTP_503')
})

it('maps a non-axios Error to UNKNOWN + DEFAULT (acceptance #2)', () => {
const result = mapApiError(new Error('boom'))
expect(result.code).toBe('UNKNOWN')
expect(result.userMessage).toBe(ERROR_MESSAGES.DEFAULT)
expect(result.retryable).toBe(false)
})

it('maps a response-less axios error to NETWORK, retryable (acceptance #3)', () => {
expect(mapApiError({ isAxiosError: true, request: {}, message: 'Network Error' })).toMatchObject({
code: 'NETWORK',
retryable: true,
})
expect(mapApiError({ request: {} }).code).toBe('NETWORK')
})

it('sets retryable: true for NETWORK and 5xx, false for 4xx', () => {
expect(mapApiError({ request: {} }).retryable).toBe(true)
expect(mapApiError({ response: { status: 500, data: {} } }).retryable).toBe(true)
expect(mapApiError({ response: { status: 502, data: {} } }).retryable).toBe(true)
expect(mapApiError({ response: { status: 400, data: {} } }).retryable).toBe(false)
expect(mapApiError({ response: { status: 409, data: { errorCode: 'ROLE_ALREADY_SET' } } }).retryable).toBe(false)
})

it('never surfaces the raw server message when showRaw is false (regression)', () => {
const leak = 'ERROR: duplicate key value violates unique constraint "users_pkey"'
const result = mapApiError(
{ response: { status: 409, data: { errorCode: 'ROLE_ALREADY_SET', message: leak } } },
{ showRaw: false },
)
expect(result.userMessage).toBe(ERROR_MESSAGES.ROLE_ALREADY_SET)
expect(result.userMessage).not.toContain('constraint')
expect(result.userMessage).not.toContain(leak)
})

it('appends the raw message only when showRaw is explicitly enabled (dev)', () => {
const result = mapApiError(
{ response: { status: 400, data: { errorCode: 'VALIDATION', message: 'amount must be > 0' } } },
{ showRaw: true },
)
expect(result.userMessage).toContain(ERROR_MESSAGES.VALIDATION)
expect(result.userMessage).toContain('amount must be > 0')
})

it('always carries the original error in raw for telemetry', () => {
const original = new Error('for reportError')
expect(mapApiError(original).raw).toBe(original)
})

it('tolerates null/undefined/string input without throwing', () => {
expect(mapApiError(null).code).toBe('UNKNOWN')
expect(mapApiError(undefined).code).toBe('UNKNOWN')
expect(mapApiError('nope').code).toBe('UNKNOWN')
expect(mapApiError(null).userMessage).toBe(ERROR_MESSAGES.DEFAULT)
})
})
98 changes: 98 additions & 0 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* API error → user-facing message mapping.
*
* One table keyed by a stable backend error code. The UI shows ONLY mapped,
* safe text — a raw server message is never interpolated into `userMessage`
* (server strings can carry internals like SQL fragments or stack hints). The
* original error travels in `raw` for telemetry (hand it to `reportError`),
* and must never be rendered to the DOM.
*
* Pure and side-effect free: it reads the error shape and returns a value.
*/

export interface ApiError {
/** Stable code: a backend error code, or a derived `NETWORK` / `HTTP_<status>` / `UNKNOWN`. */
code: string
/** HTTP status, when the request reached the server. */
status?: number
/** Safe, human text to show the user. Never contains the raw server message in production. */
userMessage: string
/** Whether retrying the same request could plausibly succeed. */
retryable: boolean
/** The original error — for telemetry only (`reportError`); never render this. */
raw?: unknown
}

/**
* Frozen code → message table. Unknown codes fall back to `DEFAULT`.
* Codes are opaque strings owned by the backend; add entries as the API grows.
*/
export const ERROR_MESSAGES: Readonly<Record<string, string>> = Object.freeze({
INSUFFICIENT_LIQUIDITY: 'Not enough liquidity in the pool for this amount.',
INSUFFICIENT_SHARES: "You don't have that many shares.",
ROLE_ALREADY_SET: "Your role is already set and can't be changed.",
UNAUTHENTICATED: 'Your session expired. Please reconnect your wallet.',
VALIDATION: 'Please check the amounts you entered.',
NETWORK: 'Network error. Check your connection and try again.',
DEFAULT: 'Something went wrong. Please try again.',
})

function asRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined
}

function readString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined
}

/**
* Dev-only switch to append the raw server message for debugging. Reads the
* build-time `VITE_SHOW_RAW_ERRORS` env (string `'true'`); defaults off and is
* never a user-controlled value. Must never be `'true'` in a production build.
*/
function defaultShowRaw(): boolean {
return import.meta.env?.VITE_SHOW_RAW_ERRORS === 'true'
}

/**
* Normalize any thrown value (AxiosError, plain Error, or unknown) into a safe
* {@link ApiError}. See the module header for the guarantees.
*
* @param err the caught value
* @param opts `showRaw` overrides the env default (used by callers/tests)
*/
export function mapApiError(err: unknown, opts?: { showRaw?: boolean }): ApiError {
const showRaw = opts?.showRaw ?? defaultShowRaw()
const record = asRecord(err)
const response = asRecord(record?.response)

let code: string
let status: number | undefined

if (response) {
// the request reached the server and came back with a status
status = typeof response.status === 'number' ? response.status : undefined
const data = asRecord(response.data)
code =
readString(data?.errorCode) ??
readString(data?.code) ??
(status !== undefined ? `HTTP_${status}` : 'UNKNOWN')
} else if (record && (record.isAxiosError === true || 'request' in record)) {
// an axios error carrying a request but no response = the call never completed
code = 'NETWORK'
} else {
// a plain Error or any non-request-shaped value
code = 'UNKNOWN'
}

const retryable = code === 'NETWORK' || (status !== undefined && status >= 500 && status < 600)

let userMessage = ERROR_MESSAGES[code] ?? ERROR_MESSAGES.DEFAULT

if (showRaw) {
const rawMessage = readString(asRecord(response?.data)?.message) ?? readString(record?.message)
if (rawMessage) userMessage = `${userMessage} [${rawMessage}]`
}

return { code, status, userMessage, retryable, raw: err }
}
Loading