Главная мина для новичка в fetch — то, что HTTP-ошибки не считаются ошибками. Promise разрешается успешно, в .catch() ничего не залетает. В коде вроде «всё хорошо», а в данных — чистый мусор.

Демонстрация ловушки

// Несуществующий пост — сервер вернёт 404:
fetch('https://jsonplaceholder.typicode.com/posts/9999')
  .then(response => response.json())
  .then(post => console.log(post.title))     // ← TypeError: Cannot read 'title' of {}
  .catch(error => console.error(error));

Что произошло. Сервер ответил со статусом 404 и пустым телом {}. fetch разрешил Promise с этим Response. response.json() распарсил пустой объект. В следующем .then мы попытались взять post.title у пустого объекта — получили undefined. На .title у undefined упало уже исключение JavaScript — вот его и поймал .catch.

Сообщение в .catch при этом будет про «Cannot read properties of undefined» — никакого упоминания 404, серверу как будто всё ок. Часы дебага в перспективе.

Почему так задумано

В отличие от старого XMLHttpRequest, где ошибки на разные коды генерились по-разному, авторы fetch приняли упрощающую идею: «сетевая ошибка — это ошибка. Всё, что вернуло хоть какой-то HTTP-ответ — это успех связи».

То есть fetch провалит Promise (попадёт в .catch) только в случаях, когда HTTP-ответа вообще не было:

  • нет интернета;
  • DNS не разрешился;
  • сервер отказал в соединении (порт закрыт);
  • TLS-handshake провалился;
  • браузер отменил запрос из-за CORS до получения ответа;
  • запрос был отменён вручную через AbortController.

Если сервер хотя бы что-то ответил — пусть 500, пусть 404, пусть 502 — для fetch это успех. Дальше уже задача программиста разбираться, что значит этот ответ.

Правильный паттерн

Проверять response.ok сразу после await fetch(...).

const response = await fetch('https://jsonplaceholder.typicode.com/posts/9999');

if (!response.ok) {
  throw new Error(`HTTP ${response.status}`);
}

const post = await response.json();
console.log(post.title);

response.ok — true, если статус в диапазоне 200–299. На 3xx, 4xx, 5xx — false. Если false — кидаем исключение, которое поймает либо обёрнутый try/catch, либо последующий .catch() в цепочке.

Универсальная обёртка

В реальных проектах эту проверку обычно выносят в утилиту, чтобы не повторять в каждом запросе:

async function api(url, options) {
  const response = await fetch(url, options);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status} ${response.statusText}`);
  }
  return response.json();
}

// Использование:
try {
  const post = await api('/api/posts/1');
  render(post);
} catch (error) {
  showError(error.message);
}

Эта обёртка — уже почти «своя мини-библиотека». К ней быстро прирастают логирование, повторные попытки, обработка специфических кодов. Чтобы не катать своё с нуля — в больших проектах берут готовое (axios, ky, ofetch), но базовый принцип в них тот же.

Что значит response.status

Помимо .ok, у Response есть числовой .status и текстовый .statusText:

response.ok          // true / false
response.status      // 200, 404, 500, ...
response.statusText  // "OK", "Not Found", "Internal Server Error"

Иногда полезно среагировать на конкретный код:

if (response.status === 401) {
  // Токен протух — перебросить пользователя на логин
  redirectToLogin();
} else if (response.status === 429) {
  // Превышен лимит — подождать и повторить
  await sleep(5000);
  return retry();
} else if (!response.ok) {
  throw new Error(`HTTP ${response.status}`);
}

Такие «умные» ветки лучше держать тоже в утилите, чтобы политика обработки была в одном месте.

Запомнить

  1. После каждого fetch первым делом — проверка response.ok.
  2. Если не ok — throw, чтобы дальнейший код не пытался работать с «успешными» данными.
  3. На спецкоды (401, 403, 429) реагируем отдельно, если есть бизнес-смысл.

В следующей главе разберём вторую сторону медали: что прилетит в .catch, когда HTTP-ответа вообще не было.