Пользователь видит «Не удалось открыть товар». Как разработчику найти причину? Соберём API каталога, который возвращает понятные ошибки и связывает каждый ответ с записью в серверном логе через идентификатор запроса.

Разберём три ситуации: неправильный номер товара, отсутствующая карточка и сбой сервера. Понадобятся основы JavaScript, терминал и современная установленная Node.js. Создадим два файла. Пример работает на встроенных модулях и вымышленных данных.

В статье про причину ошибки в JavaScript разобран Error.cause. Здесь работаем на границе сервера и клиента: какие сведения попадут в HTTP-ответ, что интерфейс покажет человеку и как разработчик найдёт этот запрос.

Один requestId связывает ответ и логКлиент получает понятную ошибку; подробности диагностики остаются на сервере.Запрос браузераGET /api/products/2СерверСоздаёт requestIdОтвет: HTTP 404PRODUCT_NOT_FOUNDrequestId: …Серверный логstatus, route, codeтот же requestIdДля 500: общий текст клиенту, диагностический код — только в логе.
По requestId из ответа можно найти запись в логе. При неожиданном сбое внутренние подробности не отправляем клиенту.

Сначала договоримся о форме ошибки

У всех ошибок будет один JSON:

{
  "error": {
    "code": "PRODUCT_NOT_FOUND",
    "message": "Товар не найден.",
    "requestId": "cc165d61-a0f7-42e8-82f2-220c699f54a2"
  }
}

code определяет ситуацию и остаётся стабильным, когда редактор меняет текст. message объясняет проблему человеку. requestId отличает один запрос от другого: его можно скопировать из интерфейса и найти в логе. Приведённый UUID — вымышленный пример.

HTTP-статус тоже нужен. Ошибка с кодом PRODUCT_NOT_FOUND получит 404, а неожиданная ошибка — 500. При статусе 200 браузер и другие клиенты будут считать ответ успешным, даже если в JSON есть слово error.

СитуацияHTTPКод в JSONСледующее действие
Номер товара содержит буквы400INVALID_IDИсправить номер
Номер корректный, товара нет404PRODUCT_NOT_FOUNDВыбрать другой товар
Маршрут не существует404ROUTE_NOT_FOUNDПроверить адрес запроса
Метод не поддерживается405METHOD_NOT_ALLOWEDИспользовать GET или HEAD
Неожиданный сбой обработчика500INTERNAL_ERRORСохранить номер запроса

Это собственный контракт учебного проекта. Для общего формата ошибок HTTP существует Problem Details, RFC 9457: у него другие поля и тип содержимого application/problem+json. Выбирайте формат вместе с клиентом; произвольно смешивать две схемы неудобно.

Один обработчик формирует все ответы об ошибках

Создайте отдельную папку api-errors-demo. В ней сохраните server.mjs:

import { createServer } from 'node:http';
import { randomUUID } from 'node:crypto';
import { readFileSync } from 'node:fs';

const page = readFileSync(new URL('./index.html', import.meta.url));
const products = [{ id: '1', name: 'Настольная лампа' }];
const diagnosticCodes = new Set(['DEMO_STORE_OFFLINE']);

class ApiError extends Error {
  constructor(status, code, message) {
    super(message);
    this.status = status;
    this.code = code;
  }
}

function sendJSON(req, res, status, data, requestId, headers = {}) {
  const body = JSON.stringify(data);
  res.writeHead(status, {
    'Content-Type': 'application/json; charset=utf-8',
    'Cache-Control': 'no-store',
    'X-Request-ID': requestId,
    ...headers,
  });
  res.end(req.method === 'HEAD' ? undefined : body);
}

createServer((req, res) => {
  const requestId = randomUUID();
  let route = 'unknown';

  try {
    const url = new URL(req.url, 'http://127.0.0.1:3000');
    const match = url.pathname.match(/^\/api\/products\/([^/]+)$/);

    if (url.pathname === '/') {
      route = 'page';
    } else if (url.pathname === '/api/demo-failure') {
      route = 'demo-failure';
    } else if (match) {
      route = 'product-detail';
    } else {
      throw new ApiError(404, 'ROUTE_NOT_FOUND', 'Адрес не найден.');
    }

    if (!['GET', 'HEAD'].includes(req.method)) {
      throw new ApiError(405, 'METHOD_NOT_ALLOWED',
        'Этот адрес поддерживает GET и HEAD.');
    }

    if (route === 'page') {
      res.writeHead(200, {
        'Content-Type': 'text/html; charset=utf-8',
        'X-Request-ID': requestId,
      });
      res.end(req.method === 'HEAD' ? undefined : page);
      return;
    }

    if (route === 'demo-failure') {
      const error = new Error('Демонстрационный сбой хранилища каталога');
      error.code = 'DEMO_STORE_OFFLINE';
      throw error;
    }

    const id = match[1];
    if (!/^[1-9]\d*$/.test(id)) {
      throw new ApiError(400, 'INVALID_ID',
        'Номер товара должен быть положительным целым числом.');
    }

    const product = products.find((item) => item.id === id);
    if (!product) {
      throw new ApiError(404, 'PRODUCT_NOT_FOUND', 'Товар не найден.');
    }

    sendJSON(req, res, 200, { product }, requestId);
  } catch (error) {
    const expected = error instanceof ApiError;
    const status = expected ? error.status : 500;
    const code = expected ? error.code : 'INTERNAL_ERROR';
    const message = expected ? error.message :
      'Не удалось выполнить запрос. Попробуйте позже.';

    const record = {
      level: expected ? 'warn' : 'error',
      requestId,
      route,
      status,
      code,
    };
    if (!expected) {
      record.internalCode = diagnosticCodes.has(error?.code) ?
        error.code : 'UNEXPECTED_ERROR';
    }
    console.error(JSON.stringify(record));

    if (res.headersSent) {
      res.destroy();
      return;
    }
    const headers = status === 405 ? { Allow: 'GET, HEAD' } : {};
    sendJSON(req, res, status, { error: { code, message, requestId } },
      requestId, headers);
  }
}).listen(3000, '127.0.0.1', () => {
  console.log('Откройте http://127.0.0.1:3000');
});

Все ошибки сходятся в catch. Ожидаемые ситуации мы создаём через ApiError: их сообщения заранее написаны для клиента. Неожиданный сбой получает 500 и общий текст, который ничего не сообщает об устройстве хранилища.

Заголовки можно отправить один раз. Если ошибка возникла после этого, res.headersSent не даст сформировать второй ответ: обработчик закроет соединение. В обычных путях ответ отправляется целиком; у HEAD остаются только статус и заголовки. Нативные методы ответа описаны в документации Node.js, обязательный Allow для 405 — в RFC 9110.

Идентификатор создаётся сервером для каждого запроса через `randomUUID()`. Мы не берём его из пользовательского заголовка. Один номер записывается в JSON, заголовок X-Request-ID и лог: по нему эти части можно сопоставить.

Лог содержит то, что помогает найти сбой

Для запроса /api/demo-failure в терминале появится одна JSON-строка примерно такого вида:

{
  "level": "error",
  "requestId": "cc165d61-a0f7-42e8-82f2-220c699f54a2",
  "route": "demo-failure",
  "status": 500,
  "code": "INTERNAL_ERROR",
  "internalCode": "DEMO_STORE_OFFLINE"
}

Клиент знает, что запрос не выполнен. Разработчик дополнительно видит, какой учебный источник сломался. Маршрут в логе обозначен фиксированным названием: например, product-detail. Полный адрес опускаем. Заголовки, тело запроса и внутренний текст исключения сюда не попадают.

Рекомендуем начать с этого короткого списка полей. Разрешённый internalCode выбирается из diagnosticCodes; неизвестные значения превращаются в UNEXPECTED_ERROR. Так внешний ввод или сообщение сторонней библиотеки не попадут в лог случайно. Когда подключите настоящую базу, добавляйте нужную диагностику осознанно и проверяйте, что в ней нет паролей и персональных данных.

requestId помогает искать запись; он не заменяет авторизацию и не даёт доступа к логам. Не создавайте публичный адрес, который выдаёт запись по этому номеру.

Интерфейс выбирает сообщение по коду ошибки

Теперь сохраните рядом index.html. Страница отправляет запрос тому же серверу, который её открыл:

<!doctype html>
<html lang="ru">
<meta charset="utf-8">
<title>Ошибка каталога</title>
<form id="lookup">
  <label for="product-id">Номер товара</label>
  <input id="product-id" name="id" value="1" required>
  <button type="submit">Открыть товар</button>
  <button type="button" id="failure">Проверить сбой сервера</button>
</form>
<p id="status" role="status"></p>
<script>
const form = document.querySelector('#lookup');
const input = document.querySelector('#product-id');
const status = document.querySelector('#status');
const failure = document.querySelector('#failure');
const buttons = form.querySelectorAll('button');
const messages = {
  INVALID_ID: 'Введите положительный целый номер товара.',
  PRODUCT_NOT_FOUND: 'Такого товара нет. Проверьте номер.',
  INTERNAL_ERROR: 'Не удалось открыть товар. Попробуйте позже.',
};
let busy = false;

async function load(path) {
  if (busy) return;
  busy = true;
  form.setAttribute('aria-busy', 'true');
  buttons.forEach((button) => button.setAttribute('aria-disabled', 'true'));
  status.textContent = 'Открываем товар…';

  try {
    let response;
    try {
      response = await fetch(path);
    } catch {
      status.textContent = 'Не удалось получить ответ сервера.';
      return;
    }

    let data;
    try {
      data = await response.json();
    } catch {
      status.textContent = 'Не удалось прочитать JSON в ответе сервера.';
      return;
    }

    if (response.ok) {
      status.textContent = typeof data?.product?.name === 'string' ?
        data.product.name : 'В ответе нет названия товара.';
      return;
    }

    const code = data?.error?.code;
    const message = typeof code === 'string' && Object.hasOwn(messages, code) ?
      messages[code] : 'Запрос завершился ошибкой.';
    const id = data?.error?.requestId;
    const validId = typeof id === 'string' &&
      /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id);
    status.textContent = message + ' HTTP ' + response.status +
      (validId ? '. Номер запроса: ' + id : '');
  } finally {
    busy = false;
    form.setAttribute('aria-busy', 'false');
    buttons.forEach((button) => button.removeAttribute('aria-disabled'));
  }
}

form.addEventListener('submit', (event) => {
  event.preventDefault();
  load('/api/products/' + encodeURIComponent(input.value.trim()));
});
failure.addEventListener('click', () => load('/api/demo-failure'));
</script>
</html>

Сначала проверяем получение ответа, затем чтение JSON и HTTP-статус. Ответ 404 не отклоняет промис fetch: его нужно обработать через response.ok. Это поведение описано в документации Fetch.

Тексты интерфейса хранятся в messages: по коду ошибки выбираем подсказку для человека. Её можно перевести или добавить действие, не меняя API. Номер запроса показываем после проверки формата. Значения вставляем через textContent, чтобы они отображались как текст.

Флаг busy разрешает только один запрос за раз. aria-disabled сообщает о временной недоступности кнопок, а сам повторный вызов блокирует функция. Кнопка сохраняет фокус. В finally снимаем ограничение при любом исходе, чтобы человек мог попробовать снова.

Проверьте всю цепочку от ответа до лога

В терминале из папки примера выполните:

node server.mjs

Откройте http://127.0.0.1:3000. Номер 1 покажет лампу, abc — подсказку о формате, 999 — сообщение об отсутствии товара. Кнопка проверки сбоя даст 500 и номер запроса.

Скопируйте этот номер и найдите его в терминале. Он должен совпасть с requestId записи с internalCode: "DEMO_STORE_OFFLINE". В ответе браузеру этого внутреннего кода и текста исключения быть не должно.

Во втором терминале проверьте статус и заголовки напрямую:

curl -i http://127.0.0.1:3000/api/products/999
curl -i http://127.0.0.1:3000/api/demo-failure
curl -i -X POST http://127.0.0.1:3000/api/products/1
curl -I http://127.0.0.1:3000/api/products/999

Первые два ответа содержат JSON ошибки. Третий возвращает 405 с Allow: GET, HEAD. Последний возвращает 404 и X-Request-ID, без тела: это проверка HEAD.

Серверный try…catch охватывает синхронные действия этого обработчика. Когда добавите промисы, сделайте обработчик асинхронным и ожидайте результат через await внутри того же блока. Ошибки событий потоков требуют своих обработчиков — Node.js различает эти механизмы.

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

Доработайте контракт и переходите к Node.js

Добавьте второй товар и проверьте успешное открытие его карточки. Затем введите новый ожидаемый код ошибки: например, для временно скрытого учебного товара. Согласуйте статус, серверный текст и действие в интерфейсе, проверьте совпадение номера запроса в ответе и логе.

Для дальнейшей работы понадобятся чтение данных, проверка ввода и устройство серверных обработчиков. На странице курса «Node.js. Профессиональная разработка REST API» можно посмотреть программу и требования к начальной подготовке.