import { getLocale, translate, type MessageKey } from '@/shared/i18n' interface Envelope { code: number message: string data?: T | null error?: string | null error_code?: string | null } export class ApiError extends Error { readonly code: number readonly errorCode?: string | null constructor(message: string, code: number, errorCode?: string | null) { super(message) this.name = 'ApiError' this.code = code this.errorCode = errorCode } } /** * Localized user-facing error text. The server's own wording is the last * resort: `error_code` and status are stable machine-readable handles that the * dictionary can translate, but a validation message built from user input is * not translatable and must pass through unchanged. */ export function toUserError(err: unknown): string { if (err instanceof ApiError) { const key = err.errorCode ? (`error.code.${err.errorCode}` as MessageKey) : null if (key && key in ERROR_KEYS) { return translate(getLocale(), key) } const byStatus = `error.status.${err.code}` as MessageKey if (byStatus in ERROR_KEYS) return translate(getLocale(), byStatus) return err.message } if (err instanceof TypeError) return translate(getLocale(), 'auth.error.network') return translate(getLocale(), 'auth.error.unknown') } /** * Key membership probe: the dictionaries are `Record` at the * boundary, so an unknown server code must be detected rather than assumed. */ const ERROR_KEYS = { 'error.code.invalid_credentials': true, 'error.code.unauthorized': true, 'error.code.token_expired': true, 'error.code.session_revoked': true, 'error.code.forbidden': true, 'error.code.not_found': true, 'error.code.conflict': true, 'error.code.rate_limited': true, 'error.code.bad_request': true, 'error.code.invalid_request_body': true, 'error.code.merge_blocked': true, 'error.code.import_disabled': true, 'error.code.import_upstream_failed': true, 'error.code.password_change_required': true, 'error.status.400': true, 'error.status.401': true, 'error.status.403': true, 'error.status.404': true, 'error.status.409': true, 'error.status.429': true, 'error.status.500': true, 'error.status.502': true, 'error.status.503': true, 'error.status.504': true, 'error.status.0': true, 'error.status.unknown': true, } as const satisfies Partial> export async function unwrap(method: PromiseLike>): Promise { const res = await method if (res.code !== 200) { throw new ApiError(res.error || res.message || 'Request failed', res.code, res.error_code) } return res.data ?? null }