В предыдущей главе разобрались, что fetch кидает исключение только при сетевых проблемах. Сейчас разберём, как такие исключения выглядят и как их обрабатывать — в одной обёртке try/catch вместе с собственными throw на HTTP-ошибки.

Что прилетает в catch

В catch попадает три разных типа ошибок — и полезно различать, какая чья.

1. Сетевая ошибка от fetch — TypeError

Если HTTP-ответа вообще не было, fetch отвергает Promise с TypeError и сообщением вроде «Failed to fetch» или «NetworkError when attempting to fetch resource»:

try {
  await fetch('https://this-domain-does-not-exist.example/');
} catch (error) {
  // error instanceof TypeError
  // error.message === "Failed to fetch" (Chrome)
  //               === "NetworkError when attempting to fetch resource." (Firefox)
}

Конкретный текст зависит от браузера. По нему ориентироваться нельзя.

2. Наш собственный throw на HTTP-ошибке

Это то, что мы сами кидаем после проверки response.ok:

try {
  const response = await fetch('/api/posts/9999');
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  // ...
} catch (error) {
  // error.message === "HTTP 404"
}

Тут уже мы сами контролируем текст. Можно кинуть свой объект ошибки с дополнительными полями.

3. Ошибка парсинга response.json()

Если в теле приехал не валидный JSON (HTML-страница ошибки сервера, обрезанный ответ), .json() кинет SyntaxError:

try {
  const response = await fetch('/api/posts/1');
  const data = await response.json();  // ← может упасть здесь
} catch (error) {
  // error instanceof SyntaxError
  // error.message === "Unexpected token < in JSON at position 0"
}

Все три типа попадают в один и тот же catch. Если нужно отличать — можно по instanceof или по флагу, который мы сами повесили на throw.

Свой класс ошибки — чище код

Когда обработка ошибок становится сложнее простого «показать сообщение», удобно завести свой класс:

class ApiError extends Error {
  constructor(status, statusText, body) {
    super(`HTTP ${status} ${statusText}`);
    this.name   = 'ApiError';
    this.status = status;
    this.body   = body;
  }
}

async function api(url, options) {
  let response;
  try {
    response = await fetch(url, options);
  } catch (error) {
    // Сюда попали — TypeError. Сеть отвалилась.
    throw new Error('Нет связи с сервером');
  }

  if (!response.ok) {
    // Попробуем достать тело ошибки.
    let body = null;
    try {
      body = await response.json();
    } catch {}
    throw new ApiError(response.status, response.statusText, body);
  }

  return response.json();
}

Теперь снаружи можно различать:

try {
  await api('/api/posts/1');
} catch (error) {
  if (error instanceof ApiError) {
    if (error.status === 401) redirectToLogin();
    else if (error.status === 429) showRateLimitMessage();
    else showError(`Ошибка сервера: ${error.message}`);
  } else {
    showError('Проверьте подключение к интернету');
  }
}

Что показывать пользователю

Универсальное правило: пользователь должен понять, что делать дальше.

  • Сетевая ошибка — «нет связи с сервером, проверьте интернет».
  • 4xx — чаще всего проблема в вводе или сессии: «проверьте поля формы» / «войдите снова». Конкретный текст обычно есть в теле ошибки — об этом следующая глава.
  • 5xx — «что-то пошло не так у нас, попробуйте через минуту». Подробности не показываем, в логи — полную ошибку.
  • SyntaxError при парсинге — обычно симптом 5xx (сервер вернул HTML-ошибку вместо JSON). Сообщение — то же «что-то пошло не так».

Никаких «TypeError: Cannot read properties of undefined (reading title)» на экране — это сообщение для разработчика, не для пользователя.

Лесенка ошибок

Если ваше приложение растёт, обработку ошибок имеет смысл собрать в один глобальный обработчик. Например, в React это ErrorBoundary, в Vue — errorCaptured, в чистом JS — window.addEventListener('unhandledrejection', ...). Чтобы случайно непойманная ошибка из fetch хотя бы попадала в систему мониторинга, а не молча терялась.

В следующей главе — как извлекать из тела ответа полезное сообщение для пользователя, а не показывать абстрактное «HTTP 422».