Клиент получает 500, а в логе лежит строка «Something went wrong». Или наоборот: API возвращает клиенту сообщение драйвера базы с SQL и именем таблицы. Обе ситуации показывают отсутствие границы между публичным контрактом и внутренней диагностикой. Хорошая обработка ошибки помогает продолжить сценарий и выяснить причину сбоя, не смешивая эти задачи.
Назовите ожидаемые исходы операции
Для GET пользователя по id нужны хотя бы три исхода: входной параметр некорректен, пользователь не найден, данные получены. Отдельно возможен неожиданный сбой хранилища. Отсутствующий ресурс не обязан становиться исключением внутри доменной функции: иногда null - понятный результат. Но транспортный слой должен последовательно преобразовать его в ответ API.
HTTP-статус отражает класс исхода, стабильный код внутри JSON помогает клиенту принять решение. Текст предназначен человеку и со временем может измениться или перевестись. Поэтому фронтенд не должен сравнивать error.message === «Пользователь не найден». Такой код сломается после редакционной правки, хотя бизнес-смысл останется прежним.
Небольшой контракт без привязки к фреймворку
TypeScript// Ожидаемая ошибка хранит только публичные сведения
export class PublicError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
message: string,
) { super(message) }
}
export function errorResponse(error: unknown): Response {
const known = error instanceof PublicError
return Response.json({
error: {
code: known ? error.code : 'INTERNAL_ERROR',
message: known ? error.message : 'Не удалось выполнить запрос',
},
}, { status: known ? error.status : 500 })
}
PublicError следует создавать только из заранее определённых безопасных сообщений. Не оборачивайте в неё исходное сообщение SQL-драйвера или провайдера. В этом учебном контракте статус хранится числом; в проекте сузьте его до согласованного набора клиентских ошибок. Не позволяйте пользовательскому вводу определять статус или публичный код.
TSX// Транспортная граница для GET пользователя
type User = { id: number; name: string }
type Repository = { find: (id: number) => Promise<User | null> }
export async function getUserResponse(
rawId: string,
repository: Repository,
report: (error: unknown) => void,
): Promise<Response> {
try {
if (!/^[1-9][0-9]*$/.test(rawId)) {
throw new PublicError(400, 'INVALID_ID', 'Нужен целочисленный id')
}
const id = Number(rawId)
if (!Number.isSafeInteger(id)) {
throw new PublicError(400, 'INVALID_ID', 'id вне допустимого диапазона')
}
const user = await repository.find(id)
if (!user) throw new PublicError(404, 'USER_NOT_FOUND', 'Пользователь не найден')
return Response.json(user)
} catch (error) {
if (!(error instanceof PublicError)) report(error)
return errorResponse(error)
}
}
Функция report - зависимость для диагностики. В настоящем приложении её сбой не должен сорвать отправку ответа: используйте устойчивый адаптер логирования. Пример не реализует авторизацию и ограничение частоты запросов. До чтения пользователя транспортный слой должен проверить право на эту операцию. Правильная обработка ошибок не заменяет контроль доступа.
Сохраняйте внутреннюю причину, а не раскрывайте её
При преобразовании низкоуровневой ошибки в ошибку приложения можно сохранять cause. Это помогает при анализе связи между отказом хранилища и операцией. Но cause и stack должны оставаться в защищённом контуре диагностики. Для связи ответа с логом полезен идентификатор запроса, который создаёт или проверяет сервер; не записывайте без разбора входные заголовки и тело.
Если каждый слой логирует одну и ту же ошибку, один сбой выглядит как множество независимых инцидентов. Выберите место регистрации и договоритесь, где добавляется контекст. Не стирайте cause строкой String(error) слишком рано. При этом сбор целого объекта запроса ради удобства может сохранить токены и персональные данные: объём диагностики должен быть осознанным.
Что проверить автоматически
- INVALID_ID: отрицательные, дробные, пустые и слишком большие значения дают 400 и не вызывают репозиторий.
- USER_NOT_FOUND: корректный id и null от репозитория дают 404 с согласованным кодом.
- Успех: ответ 200 содержит ожидаемые данные, а не обёрнутую ошибку.
- Сбой репозитория: ответ 500 не содержит исходный message и stack; диагностика получает причину ровно на выбранной границе.
- Фронтенд: сетевой отказ и ответ с error обрабатываются отдельно; повтор запроса не очищает пользовательский ввод.
Async, события и перезапуск
catch вокруг await ловит rejected Promise из этой цепочки. Он не перехватывает произвольное событие error у EventEmitter и не покрывает callback, который запустится позже вне цепочки. Проверяйте контракт конкретного API. После неожиданного состояния процесса не пытайтесь сделать вид, что всё исправлено одним глобальным обработчиком: стратегия завершения и восстановления процесса требует отдельного проектирования.
Попробуйте использовать пример с тестовым репозиторием, затем подключить его к проверке unknown на границе клиента. API-контракт входит в backend-программу вместе с данными, тестами и инфраструктурой.
Документация и ограничения
Механизмы ошибок и cause: Node.js 22 - Errors. Значения статусов: MDN HTTP Status. Пример демонстрирует транспортный контракт, а не готовый защищённый endpoint.