Когда сервер возвращает ошибку, он обычно прикладывает в теле какие-то подробности: что именно пошло не так, какое поле невалидно, как это исправить. Их полезно достать и показать пользователю — иначе на каждый 422 будет выскакивать абстрактное «что-то пошло не так», и пользователь не поймёт, что делать.

Самый частый формат

Чаще всего тело ошибки выглядит так:

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{
  "message": "Email уже занят",
  "code": "EMAIL_TAKEN"
}

Достать — обычным response.json():

const response = await fetch('/api/users', { method: 'POST', body: JSON.stringify(data) });

if (!response.ok) {
  const error = await response.json();
  showError(error.message);   // "Email уже занят"
  return;
}

Но у каждого API формат свой. Чтобы не сюрпризовать пользователя, проверяем структуру:

if (!response.ok) {
  let message = `Ошибка ${response.status}`;   // fallback
  try {
    const body = await response.json();
    if (body.message) message = body.message;
    else if (body.error) message = body.error;
    else if (typeof body === 'string') message = body;
  } catch {
    // тело не JSON — ничего страшного, оставим fallback
  }
  showError(message);
  return;
}

Валидационные ошибки на форму

Когда невалидны несколько полей формы, сервер обычно присылает их списком:

HTTP/1.1 422 Unprocessable Entity

{
  "message": "Validation failed",
  "errors": {
    "email": ["Не похоже на email"],
    "password": ["Минимум 8 символов", "Должен содержать цифру"]
  }
}

Удобный паттерн — раздать ошибки по полям формы:

if (!response.ok) {
  const body = await response.json();

  if (body.errors) {
    for (const [field, messages] of Object.entries(body.errors)) {
      const input = document.querySelector(`[name="${field}"]`);
      const helper = input?.nextElementSibling;
      if (helper) helper.textContent = messages.join('. ');
    }
  } else {
    showError(body.message ?? 'Ошибка сервера');
  }
  return;
}

Конкретные ключи (errors, violations, fieldErrors) различаются от API к API. Прочитайте документацию или сделайте один тестовый запрос с заведомо невалидными данными — и посмотрите, что приходит.

RFC 7807 — стандартный формат проблем

Был придуман специальный стандарт — Problem Details for HTTP APIs (RFC 7807). Сервер возвращает Content-Type: application/problem+json с такой структурой:

{
  "type":   "https://api.example.com/errors/email-taken",
  "title":  "Email уже занят",
  "status": 422,
  "detail": "Пользователь с email anna@example.com уже зарегистрирован",
  "instance": "/api/users"
}

На практике встречается редко — чаще API катят свой формат. Но если попался сервис, который придерживается RFC 7807 — ключи title и detail идеально ложатся в «заголовок и подробность» для тоста с ошибкой.

Когда тело — не JSON

Старые сервера, прокси-страницы Cloudflare, ошибки на уровне инфраструктуры — иногда вместо JSON возвращают HTML или plain-text. Попытка response.json() упадёт с SyntaxError.

Защититься можно так:

const contentType = response.headers.get('Content-Type') || '';

let body;
if (contentType.includes('application/json')) {
  body = await response.json();
} else {
  body = await response.text();   // HTML или plain — как строка
}

Логи такого ответа полезно собирать в систему мониторинга: 9 из 10 раз это симптом, что что-то сломалось не в самом API, а на уровне инфраструктуры.

Полная схема в одном куске кода

async function api(url, options) {
  let response;
  try {
    response = await fetch(url, options);
  } catch {
    throw new Error('Нет связи с сервером');
  }

  const contentType = response.headers.get('Content-Type') || '';
  const isJson = contentType.includes('application/json');

  if (!response.ok) {
    let body = null;
    if (isJson) {
      try { body = await response.json(); } catch {}
    }
    const message = body?.message ?? body?.error ?? `HTTP ${response.status}`;
    throw new ApiError(response.status, message, body);
  }

  return isJson ? response.json() : response.text();
}

Эта схема покрывает большинство практических кейсов и аккуратно отделяет «сообщение для пользователя» от «что вообще произошло».

В последней главе модуля разберём, как отменять зависшие запросы и делать таймауты — ещё одна сторона надёжной работы с сетью.