Стандарты

Error Handling

Стандарт обработки ошибок — классификация, middleware, логирование

Error Handling

Принцип

Ошибки классифицируются, обрабатываются централизованно и никогда не замалчиваются. Пользователь получает понятное сообщение, разработчик — полный контекст.

Приоритет: P0 — обязательно для backend и frontend.

Классификация ошибок

ТипHTTP кодДействиеПример
Validation400Показать пользователю, какие поля невалидныНекорректный email
Authentication401Перенаправить на логинПротухший токен
Authorization403Показать "Нет доступа"Чужой ресурс
Not Found404Показать "Не найдено"Несуществующий ID
Conflict409Объяснить конфликтEmail уже занят
Internal500Логировать, показать "Что-то пошло не так"Сбой БД

Backend: AppError

Все бизнес-ошибки выбрасываются как AppError — кастомный класс с кодом и HTTP-статусом.

// server/utils/errors.ts
export class AppError extends Error {
  constructor(
    public readonly code: string,
    message: string,
    public readonly statusCode: number = 400,
    public readonly details?: Record<string, unknown>,
  ) {
    super(message)
    this.name = 'AppError'
  }
}

// Фабрики для частых случаев
export function notFound(entity: string, id?: string): AppError {
  const msg = id ? `${entity} с id ${id} не найден` : `${entity} не найден`
  return new AppError(`${entity.toUpperCase()}_NOT_FOUND`, msg, 404)
}

export function conflict(message: string): AppError {
  return new AppError('CONFLICT', message, 409)
}

export function forbidden(message = 'Нет доступа'): AppError {
  return new AppError('FORBIDDEN', message, 403)
}

Использование в сервисах

// server/services/task.service.ts
import { notFound, forbidden } from '~/server/utils/errors'

export const TaskService = {
  async update(id: string, userId: string, input: UpdateTaskInput) {
    const task = await prisma.task.findUnique({ where: { id } })
    if (!task) throw notFound('Task', id)
    if (task.ownerId !== userId) throw forbidden('Только автор может редактировать задачу')

    return prisma.task.update({ where: { id }, data: input })
  },
}

Backend: Global Error Handler

Centralized middleware перехватывает все ошибки и формирует единый формат ответа.

// server/middleware/error-handler.ts
export default defineEventHandler((event) => {
  event.context._onError = true
})

// server/plugins/error-handler.ts
import { AppError } from '~/server/utils/errors'

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('error', (error, { event }) => {
    if (!event) return

    if (error instanceof AppError) {
      setResponseStatus(event, error.statusCode)
      return send(event, JSON.stringify({
        error: {
          code: error.code,
          message: error.message,
          ...(error.details && { details: error.details }),
        },
      }), 'application/json')
    }

    // Zod validation errors
    if (error.name === 'ZodError') {
      setResponseStatus(event, 400)
      return send(event, JSON.stringify({
        error: {
          code: 'VALIDATION_ERROR',
          message: 'Ошибка валидации',
          details: error.issues,
        },
      }), 'application/json')
    }

    // Prisma known errors
    if (error.code === 'P2025') {
      setResponseStatus(event, 404)
      return send(event, JSON.stringify({
        error: {
          code: 'NOT_FOUND',
          message: 'Запись не найдена',
        },
      }), 'application/json')
    }

    // Unexpected errors — логируем полностью, отдаём минимум
    console.error('[UNHANDLED]', error)
    setResponseStatus(event, 500)
    return send(event, JSON.stringify({
      error: {
        code: 'INTERNAL_ERROR',
        message: 'Внутренняя ошибка сервера',
      },
    }), 'application/json')
  })
})

Backend: Формат ответа об ошибке

Все ошибки API возвращаются в едином формате:

{
  "error": {
    "code": "USER_NOT_FOUND",
    "message": "Пользователь с id abc123 не найден",
    "details": {}
  }
}

Поле details опционально — используется для ошибок валидации (список полей) или для дополнительного контекста.

Frontend: Обработка ошибок API

Composable для API-вызовов

// app/composables/useApiError.ts
interface ApiError {
  code: string
  message: string
  details?: Record<string, unknown>
}

export function useApiError() {
  const toast = useToast()

  function handleError(error: unknown) {
    const apiError = extractApiError(error)

    if (apiError.code === 'VALIDATION_ERROR') {
      // Валидационные ошибки обрабатываются формой
      return apiError
    }

    // Остальные — toast-уведомление
    toast.add({
      title: 'Ошибка',
      description: apiError.message,
      color: 'error',
    })

    return apiError
  }

  return { handleError }
}

function extractApiError(error: unknown): ApiError {
  if (error && typeof error === 'object' && 'data' in error) {
    const data = (error as any).data
    if (data?.error) return data.error
  }
  return { code: 'UNKNOWN', message: 'Произошла непредвиденная ошибка' }
}

Обработка в компонентах

<script setup lang="ts">
const { handleError } = useApiError()

async function onSubmit(data: CreateUserInput) {
  try {
    await useAPI('/api/users', { method: 'POST', body: data })
    navigateTo('/users')
  } catch (error) {
    handleError(error)
  }
}
</script>

Серверные ошибки валидации в формах

Когда сервер возвращает ошибки валидации, они отображаются на соответствующих полях:

<script setup lang="ts">
const serverErrors = ref<Record<string, string>>({})

async function onSubmit(data: CreateUserInput) {
  serverErrors.value = {}
  try {
    await useAPI('/api/users', { method: 'POST', body: data })
  } catch (error) {
    const apiError = extractApiError(error)
    if (apiError.code === 'VALIDATION_ERROR' && apiError.details) {
      // Маппим серверные ошибки на поля формы
      for (const issue of apiError.details as any[]) {
        serverErrors.value[issue.path.join('.')] = issue.message
      }
    }
  }
}
</script>

Frontend: Error Boundaries

Nuxt предоставляет error.vue для глобальных ошибок и <NuxtErrorBoundary> для локальных.

<!-- app/error.vue — глобальная страница ошибки -->
<template>
  <UContainer>
    <UPageError
      :status="error?.statusCode || 500"
      :name="error?.statusCode === 404 ? 'Страница не найдена' : 'Ошибка'"
      :message="error?.message || 'Что-то пошло не так'"
    />
  </UContainer>
</template>

<script setup lang="ts">
defineProps<{ error: { statusCode: number; message: string } }>()
</script>

Правила

Что делать

  • Выбрасывай AppError в сервисах — с кодом, сообщением и HTTP-статусом
  • Валидируй входные данные на границе (хэндлер) через Zod
  • Логируй unexpected errors с полным стеком
  • Показывай пользователю понятные сообщения на русском
  • Используй toast-уведомления для операционных ошибок
  • Используй error pages для навигационных ошибок (404, 500)

Чего не делать

  • Не замалчивай ошибки — catch {} без обработки
  • Не показывай пользователю stack trace или внутренние детали
  • Не используй createError в сервисах — это HTTP-специфичная функция Nitro
  • Не дублируй обработку ошибок — один global handler на сервере
  • Не используй magic strings для кодов ошибок — используй константы

Чек-лист

  • Все бизнес-ошибки — AppError с кодом и статусом
  • Global error handler перехватывает все ошибки в едином формате
  • Zod errors автоматически конвертируются в VALIDATION_ERROR
  • Prisma P2025 автоматически конвертируется в 404
  • Unexpected errors логируются, но пользователь видит generic сообщение
  • Frontend показывает ошибки через toast или error page
  • Серверные ошибки валидации маппятся на поля формы

Связанные документы