Главная мина для новичка в 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}`);
}
Такие «умные» ветки лучше держать тоже в утилите, чтобы политика обработки была в одном месте.
Запомнить
- После каждого fetch первым делом — проверка response.ok.
- Если не ok — throw, чтобы дальнейший код не пытался работать с «успешными» данными.
- На спецкоды (401, 403, 429) реагируем отдельно, если есть бизнес-смысл.
В следующей главе разберём вторую сторону медали: что прилетит в .catch, когда HTTP-ответа вообще не было.