fix(ux): friendly error UI вместо raw AxiosError

This commit is contained in:
Александр Зимин
2026-05-12 12:41:48 +00:00
parent db0986d235
commit 3984d99004
14 changed files with 274 additions and 57 deletions
@@ -0,0 +1,207 @@
import { useTranslation } from 'react-i18next'
import axios from 'axios'
import { Alert } from './alert'
import { Button } from './button'
import { ArrowClockwiseIcon } from '@phosphor-icons/react'
/**
* Friendly error renderer для TanStack Query errors. Замена паттерна
* {@code <Alert variant="error">{String(error)}</Alert>} который раздавал
* raw {@code "AxiosError: Request failed with status code 503"} в UI.
*
* <p>Маппинг status → human message:
* <ul>
* <li>503/502/504 — сервер временно недоступен (rolling restart, GW timeout)</li>
* <li>500 — внутренняя ошибка сервера</li>
* <li>404 — не найдено</li>
* <li>403 — нет доступа</li>
* <li>401 — handled global'ным interceptor'ом, но fallback'нем</li>
* <li>network err — нет соединения</li>
* <li>default — generic failure с raw message только в debug режиме</li>
* </ul>
*
* <p>Backend code/message парсятся если возвращены: вместо `AxiosError: ...`
* показывается {@code body.message} (что backend сам положил). Это работает
* только для structured 4xx responses; 5xx обычно plain html error page.
*
* <p>Retry кнопка — opt-in через {@code onRetry} prop. Caller передаёт
* {@code () => query.refetch()}.
*/
export type QueryErrorStateProps = {
/** TanStack Query error (или любая Error/unknown) */
error: unknown
/** Optional retry callback — обычно `() => query.refetch()`. */
onRetry?: () => void
/** Optional title override (по умолчанию по status code'у). */
title?: React.ReactNode
/** className для Alert wrapper (e.g. mt-4). */
className?: string
}
type ErrorClassification = {
titleKey: string
titleFallback: string
bodyKey: string
bodyFallback: string
/** Можно retry'нуть. False для permanent errors (404/403). */
retryable: boolean
/** Backend-provided code (если был structured response). */
backendCode?: string
/** Backend-provided human message (если был). */
backendMessage?: string
}
function classify(error: unknown): ErrorClassification {
if (axios.isAxiosError(error)) {
const status = error.response?.status
const body = error.response?.data as
| { code?: string; message?: string; error?: string }
| undefined
const backendCode = body?.code
const backendMessage = body?.message ?? body?.error
// Network error (no response, e.g. DNS, offline, CORS preflight failed).
if (!error.response) {
return {
titleKey: 'queryError.network.title',
titleFallback: 'Нет соединения с сервером',
bodyKey: 'queryError.network.body',
bodyFallback: 'Проверьте сеть. Когда соединение восстановится — повторите.',
retryable: true,
}
}
if (status === 503 || status === 502 || status === 504) {
return {
titleKey: 'queryError.unavailable.title',
titleFallback: 'Сервер временно недоступен',
bodyKey: 'queryError.unavailable.body',
bodyFallback:
'Возможно идёт обновление или плановое обслуживание. Подождите несколько секунд и повторите.',
retryable: true,
}
}
if (status === 500) {
return {
titleKey: 'queryError.server.title',
titleFallback: 'Внутренняя ошибка сервера',
bodyKey: 'queryError.server.body',
bodyFallback:
'Что-то пошло не так на стороне сервера. Если ошибка повторяется — сообщите администратору.',
retryable: true,
backendCode,
backendMessage,
}
}
if (status === 404) {
return {
titleKey: 'queryError.notFound.title',
titleFallback: 'Не найдено',
bodyKey: 'queryError.notFound.body',
bodyFallback: 'Запрашиваемые данные не существуют или были удалены.',
retryable: false,
backendCode,
backendMessage,
}
}
if (status === 403) {
return {
titleKey: 'queryError.forbidden.title',
titleFallback: 'Нет доступа',
bodyKey: 'queryError.forbidden.body',
bodyFallback: 'У вас нет прав на просмотр этих данных.',
retryable: false,
backendCode,
backendMessage,
}
}
if (status === 401) {
return {
titleKey: 'queryError.unauthorized.title',
titleFallback: 'Требуется вход',
bodyKey: 'queryError.unauthorized.body',
bodyFallback: 'Сессия истекла. Войдите снова чтобы продолжить.',
retryable: false,
}
}
if (status && status >= 400 && status < 500) {
return {
titleKey: 'queryError.client.title',
titleFallback: 'Запрос некорректен',
bodyKey: 'queryError.client.body',
bodyFallback: backendMessage ?? 'Сервер отклонил запрос.',
retryable: false,
backendCode,
backendMessage,
}
}
}
// Non-axios error: Error instance or unknown
const msg =
error instanceof Error
? error.message
: typeof error === 'string'
? error
: 'unknown error'
return {
titleKey: 'queryError.generic.title',
titleFallback: 'Не удалось загрузить данные',
bodyKey: 'queryError.generic.body',
bodyFallback: msg,
retryable: true,
}
}
export function QueryErrorState({
error,
onRetry,
title,
className,
}: QueryErrorStateProps) {
const { t } = useTranslation()
const c = classify(error)
// Backend message приоритетней generic — если backend сказал что-то
// понятное в body.message, покажем это. Иначе fallback.
const body = c.backendMessage
? c.backendMessage
: t(c.bodyKey, { defaultValue: c.bodyFallback })
// Alert.title типизирован как HTML title (string) из-за HTMLAttributes
// intersection с custom React.ReactNode. Stringify чтобы TS не ругался —
// в practice title всегда plain string label.
const titleStr =
typeof title === 'string'
? title
: String(title ?? t(c.titleKey, { defaultValue: c.titleFallback }))
return (
<Alert
variant="error"
title={titleStr}
className={className}
action={
c.retryable && onRetry ? (
<Button
variant="ghost"
size="sm"
onClick={onRetry}
leftIcon={<ArrowClockwiseIcon weight="regular" size={14} />}
>
{t('queryError.retry', { defaultValue: 'Повторить' })}
</Button>
) : undefined
}
>
<div className="space-y-1">
<div className="text-body">{body}</div>
{c.backendCode && (
<div className="text-cap text-mute font-mono uppercase tracking-wider">
code: {c.backendCode}
</div>
)}
</div>
</Alert>
)
}