Эта статья — про полный цикл: как устроен Request/Response, как корректно отправлять методы и тела, как читать статусы и не путать HTTP-ошибку с сетевой, как отменять, ретраить, кэшировать и пагинировать REST-запросы, и как отображать полученное так, чтобы UI оставался предсказуемым и безопасным.
Материал рассчитан на vanilla-код без axios/react-query — они решают часть проблем за вас, но понимать, что под капотом, полезно и для отладки, и для критичных мест. По состоянию на октябрь 2026; всё разобранное работает в актуальных Chromium/Firefox/Safari (точные версии внедрения конкретных методов сверяйте на caniuse — там, где не приводятся номера релизов, это сделано сознательно).
Часть I. Как устроен fetch: Request, Response и «не путать сетевую ошибку с HTTP-ошибкой»
1. Что это вообще за API
fetch — это современный браузерный интерфейс к HTTP-запросу. Он возвращает Promise, который резолвится в Response — объект, несущий статус, заголовки, «горелый» body (поток байт). Тело уже приехало в сеть, но ещё не прочитано в память: .json()/.text()/.arrayBuffer()/.bytes()/.blob()/.formData() читают поток и возвращают свой promise. Тело читается один раз — второй вызов response.json() бросит TypeError: body stream already read.
В отличие от XMLHttpRequest, который был одновременно «функцией, объектом и эмиттером событий» и имел четыре уровня вложенных callback-ов, fetch — это:
fetch(input, init) → Promise<Response> → Response.{status, headers, body, ok} → Response.json()/text()/... → Promise<данные>
input — это либо строка-URL, либо готовый Request. init — «опции»: метод, заголовки, тело, режим кэша, signal и пр. Можно собрать их и в new Request(url, init), и передать объект одним куском — так удобно, если запрос «неизвестен на момент определения вызова».
2. Базовый GET: два await, а не один
Самая распространённая ошибка новичка — «я получил Response, а данных нет»:
// правильно
const res = await fetch('https://api.example.test/users/42');
if (!res.ok) {
// ...обработка
}
const user = await res.json(); // второй await читает тело
console.log(user.name);
// типичный провал
const user = await fetch('https://api.example.test/users/42');
// user — это Response, у него нет user.name
Оба await обязательны, потому что оба действия (получить заголовки + прочитать поток) — это две разные сетевые «стадии». Отсюда и «тело уже прочитано»: если первый .json() не понравился формату ответа и бросил, второй прочитать поток уже не сможет.
3. fetch vs XHR — почему «сработало в прошлый раз»
Несколько осознанных изменений против эпохи XMLHttpRequest:
| Что | XHR | fetch |
|---|---|---|
| Отмена | xhr.abort() | AbortController.signal |
| Прогресс загрузки | onprogress из коробки | только через response.body ReadableStream (загрузка тела запроса пока вне стандарта) |
| Кросс-доменные куки | xhr.withCredentials = true | credentials: 'include' |
| Таймаут | xhr.timeout | только через AbortSignal.timeout() |
| Стриминг ответа | нет | есть — response.body.getReader() |
Отсутствие встроенного «прогресса тела запроса» — это то, ради чего до сих пор в UI-загрузке файлов иногда оставляют XHR или пишут свой ReadableStream-обёрток (например, в XMLHttpRequest можно setRequestHeader('Content-Length', ...)). Для REST-чтений это не мешает.
4. Что на самом деле делает fetch под капотом
fetch не «посылит запрос» — он создаёт HTTP-транзакцию в браузерном HTTP-стеке, который сам делает DNS, TCP, TLS, применяет HTTP/2/HTTP/3, читает и использует кэш (Cache-Control, ETag, Last-Modified). Из этого следуют две неочевидные вещи:
fetchпрозрачно применяет browser-кэш. Если сервер сказалCache-Control: max-age=600, второй вызов в течение 10 минут может не уйти в сеть вообще — вы получитеResponseиз диска/памяти браузера. Для API это часто удивление; об этом — в §17.fetchсам делает CORS-проверку. Если сервер не вернулAccess-Control-Allow-Origin, ваш код получитTypeError: Failed to fetch— и не узнает статус ответа (браузер специально не даёт «подглядеть» за чужую зону). Это не баг fetch; об этом в §18.
Часть II. Запрос: метод, заголовки, тело
5. POST JSON: ровно три места, где можно ошибиться
async function createUser(data) {
const res = await fetch('https://api.example.test/users', {
method: 'POST', // иначе GET, даже с body: GET + body — законно, но API его не прочитает
headers: {
'Content-Type': 'application/json', // говорит серверу, КАК парсить
'Accept': 'application/json', // говорит серверу, ЧТО вы хотите в ответе
},
body: JSON.stringify(data), // строка, а не объект; fetch сам «не превратит» объект
});
if (!res.ok) throw new Error(`createUser: ${res.status}`);
return res.status === 204 ? null : res.json();
}
Три ошибки, которые ловятся ревью:
body: dataвместоJSON.stringify(data). Вfetchнет «магии сериализации»: если передать объект, браузер прочитает его как[object Object]и уйдёт в сеть мусор.- Забыть
Content-Type. Многие API вернут415 Unsupported Media Type; другие молча примутtext/plainи не распарсят JSON. - Забыть
Accept. Сервер, у которого несколько представлений (JSON/HTML/XML), по умолчанию может выдать «что просит URL», а не «что вам нужно». ДержитеAccept: application/jsonв каждом вызове API, даже если «там всегда JSON».
6. GET c query-параметрами — только через URL/URLSearchParams
Конкатенация строк в URL — это SQL-инъекция в миниатюре: вы забудете encodeURIComponent и получите поломку/обход фильтров:
// плохая практика
const url = `https://api.example.test/users?q=${query}&tag=${tag}`;
// если query = 'a&b=c' — подменится параметр, сервер увидит q='a', b='c', tag=...
// правильно
const url = new URL('https://api.example.test/users');
url.search = new URLSearchParams({ q: query, tag }).toString();
// query = 'a&b=c' уйдёт как q=a%26b%3Dc
const res = await fetch(url);
URL умеет в путь подставлять сегменты через pathname, и если часть пути приходит от пользователя — тоже кодируйте: encodeURIComponent(id). Для чисел/идентификаторов, собранных валидатором, — не требуется; для строк от пользователя — всегда.
7. Форма и multipart: FormData — но не трогайте Content-Type
const fd = new FormData();
fd.append('title', form.title.value);
fd.append('cover', fileInput.files[0]); // File/Blob
const res = await fetch('https://api.example.test/posts', {
method: 'POST',
body: fd,
// НЕ ставим 'Content-Type' — браузер сам поставит multipart/form-data; boundary=...
// если поставить вручную без boundary, сервер не сможет распарсить тело
});
Это самый частый «почему всё раньше работало» случай: вы поставили Content-Type: multipart/form-data, забыли boundary — и сервер видит пустое тело. Для application/x-www-form-urlencoded тоже есть shortcut:
const res = await fetch('https://api.example.test/login', {
method: 'POST',
body: new URLSearchParams({ user, pass }),
// fetch сам поставит Content-Type: application/x-www-form-urlencoded;charset=UTF-8
});
8. Заголовки авторизации, куки и «who am I» в REST
- Bearer-токен:
headers: { Authorization: 'Bearer ' + token }— основной паттерн для API. - Куки:
fetchне шлёт куки на чужой origin безcredentials: 'include'. Даже тогда сервер должен выставитьAccess-Control-Allow-Credentials: true, иAccess-Control-Allow-Originобязан быть точным (без*). Ссылка на детальную разборку CORS — в статье «Защита API: rate-limiting, валидация, CORS, ключи». - Идempotency-Key для критичных POST (платежи, «оформить заказ»): клиент придумывает UUID и шлёт в заголовке; сервер хранит и на повтор вернёт тот же ответ, а не создаст запись второй раз. Стандарт де-факто, поддерживается Stripe и аналогами.
async function pay(order) {
const res = await fetch('https://api.example.test/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${token}`,
'Idempotency-Key': crypto.randomUUID(), // один и тот же при повторе
},
body: JSON.stringify(order),
});
return res.json();
}
Часть III. Ответ: статусы, .ok, и как читать ошибку как человек
9. response.ok и «fetch не бросает на 4xx/5xx»
Спецификация: ok === true тогда и только тогда, когда status лежит в [200, 299]. Все 3xx, 4xx, 5xx — это успешно завершившийся fetch с ok: false. Ошибка в catch будет только при сетевом провале (DNS, TLS, «connection refused», обрыв, CORS-блокировка — и то как TypeError: Failed to fetch без детали).
Поэтому канонический «человеческий» wrapper:
async function api(path, init = {}) {
// вынесение заголовков и тела — чтобы вызывающий код не дублировал boilerplate каждый раз
const opts = { ...init, headers: { Accept: 'application/json', ...init.headers } };
// если передали plain-объект как body и это не FormData/URLSearchParams/String — сериализуем в JSON
const isSpecialBody =
typeof init.body === 'string' ||
init.body instanceof FormData ||
init.body instanceof URLSearchParams ||
init.body instanceof Blob;
if (init.body && !isSpecialBody) {
opts.body = JSON.stringify(init.body);
opts.headers['Content-Type'] ??= 'application/json';
}
const res = await fetch(new URL(path, 'https://api.example.test/'), opts);
// НЕ «throw new Error(...)» одной строкой: caller обязан видеть status/код
if (!res.ok) {
let body = null;
try { body = await res.json(); } catch { /* 204, HTML, пустое — нормально */ }
const err = new Error(body?.message || `HTTP ${res.status}`);
err.status = res.status;
err.code = body?.code; // машинный код, если API его отдаёт
err.detail = body;
throw err;
}
if (res.status === 204) return null; // «успех без тела» — не вызывать .json()
const ct = res.headers.get('content-type') || '';
if (ct.includes('application/json')) return res.json();
return res.text();
}
Правило, из которого дальше всё выводится: не глотайте статус — кладите в err.status, чтобы UI мог различить «не авторизован (401) — редирект на логин» от «сервер умер (503) — покажи баннер и ретрай» и от «валидации нет (400) — подсветь поля».
10. Коды REST, которые должен знать клиент
| Код | Когда | Что делать |
|---|---|---|
| 200 | ОК с телом | читать .json() |
| 201 | Ресурс создан | читать Location заголовок, возможно обновить список |
| 202 | Принято в очередь | показать «в обработке», поллить или ждать SSE/websocket |
| 204 | Успех без тела | не вызывать .json(), он упадёт |
| 304 | Not Modified | браузер обслуживает прозрачно: вам придёт status 200 из кэша, если вы его не отключали; вручную ловят редко |
| 400 | Плохой запрос | подсветка формы/ошибка клиента, retry — только после правки |
| 401 | Не аутентифицирован | обновить токен / уйти на login |
| 403 | Нет прав | не редиректить, показать 403-UI |
| 404 | Нет ресурса | показать «страница не найдена», не retry |
| 409 | Конфликт | «уже есть», предложить слияние / перечитать |
| 429 | Rate limit | читать Retry-After, выключить ретрай и поставить backoff |
| 500–504 | Ошибка сервера / гейтвея | retry с backoff и jitter |
Разница 401/403 критична: первый — «войди заново», второй — «ты вошёл, но не можешь». Смешивать их в «просто unauthorized» — типичная ошибка UI-авторизации.
11. Обработка ошибок в async-цепочке
try {
const user = await api('/users/42');
render(user);
} catch (err) {
if (err.name === 'AbortError') {
// это мы сами отменили — НЕ ошибка, НЕ пишем в лог
return;
}
if (err.status === 401) {
redirectToLogin();
return;
}
if (err.status === 429) {
const retryAfter = Number(err.detail?.retryAfter ?? 0);
scheduleRetry(retryAfter * 1000);
return;
}
if (err.status >= 500) {
showOfflineBanner();
return;
}
// всё остальное — нормальный «покажи сообщение»
showInlineError(err.message);
}
Отдельная ловушка: AbortError — это не ошибка в бизнес-смысле, это результат вашей отмены. В catch его обязательно игнорируйте, иначе UI мигнёт «не удалось загрузить» на каждый ввод символа в поиск (см. Часть IV и §19).
Часть IV. Отмена и таймауты: AbortController, AbortSignal.timeout, AbortSignal.any
12. Базовая отмена
const ac = new AbortController();
const p = fetch('https://api.example.test/slow', { signal: ac.signal });
// позже — по кнопке «Отмена», при размонтировании, при уходе со страницы:
ac.abort();
try { await p; }
catch (e) {
if (e.name === 'AbortError') { /* отменено — штатно */ }
else throw e;
}
Отменив signal, вы: (1) обрываете TCP/TLS-стрим, (2) реджектите Promise fetch и всех его await-ов с DOMException name === 'AbortError', (3) останавливаете чтение response.body. Это единственная корректная стратегия, потому что «просто перестать await-ить» не отменяет сетевой запрос — он доест трафик и, если у вас есть race-обработчик, всё равно перезапишет результат.
13. Таймаут в одну строку: AbortSignal.timeout(ms)
Раньше таймаут делали так: ставили setTimeout(() => ac.abort(), 5000), чистили его после ответа, писали обёртку. Теперь есть встроенный комбинатор:
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
// если за 5 секунд не получен header-ответ — fetch реджектится с name === 'TimeoutError'
name === 'TimeoutError' отличается от AbortError — это тоже «не бизнес-ошибка», но её стоит обработать отдельно: часто это повод показать «попробовать ещё раз» и залогировать медленный endpoint.
14. Комбинация: отмена по «пользователь ушёл» + «истёк таймаут»
const ac = new AbortController();
const signal = AbortSignal.any([ac.signal, AbortSignal.timeout(8000)]);
try {
await fetch(url, { signal });
} catch (e) {
if (e.name === 'AbortError') return; // вручную отменили — молча
if (e.name === 'TimeoutError') showRetry(); // таймаут — показать retry
throw e;
}
AbortSignal.any([...]) — новый стандарт (есть в актуальных Chromium/Firefox/Safari). Раньше приходилось писать свой «mux», теперь — один вызов. Он создаёт производный signal, который «срабатывает, если сработал любой из переданных», и аккуратно снимает listeners.
15. «Одно окно — один в-flight»
Хорошая практика — держать контроллер не в функции, а в состоянии области. При смене фильтра, маршрута, вводе в поиске — не создавать «ещё один» fetch, а отменять предыдущий:
let current = null;
function loadUsers(q) {
current?.abort(); // 1. отменяем предыдущий
const ac = new AbortController();
current = ac;
api(`/users?q=${encodeURIComponent(q)}`, { signal: ac.signal })
.then(users => {
if (current !== ac) return; // 2. на всякий случай: гонка всё равно может быть
renderList(users);
})
.catch(err => {
if (err.name === 'AbortError') return;
showError(err);
});
}
Это самый компактный паттерн «не дать UI прыгать туда-сюда» без библиотек. Дублирование защиты (abort + current !== ac) — не перестраховка: abort() асинхронен, и теоретически .then может уже сидеть в микротаске.
Часть V. REST-конвенции, которые должен знать клиент
16. Семантика методов
- GET — безопасный (не меняет состояние сервера) и кэшируемый. Не шлите
body. Если API «хитрый» и требует POST для поиска (graphql-подобные) — это уже не REST, и знайте, что теряете кэш. - HEAD — тот же GET без тела; удобен для проверки «существует / обновлён».
- POST — не идемпотентен: повтор создаёт дубль. Нужен идемпотентный ключ (§8), если это критичная запись.
- PUT — идемпотентен, «полная замена ресурса по URI». Повтор даёт тот же результат.
- PATCH — частичная замена. Идемпотентность не гарантируется стандартом, но в хороших API PATCH по состоянию делает идемпотентным.
- DELETE — идемпотентен в смысле «после первого — 204, после второго — 404». Клиент часто трактует и 204, и 404 как «уже удалён».
17. Кэширование: Cache-Control, ETag и fetch-режимы
Сервер говорит Cache-Control: max-age=60 — клиент 60 секунд получает ответ из браузерного кэша без похода в сеть. Сервер говорит ETag: "a1b2c3" — на повторном запросе браузер приложит If-None-Match: "a1b2c3", сервер ответит 304, а fetch отдаст вам закешированное тело как обычный status 200 (вы 304 не увидите). Это нормально, но важно знать: «почему запросы не видны в Network» часто объясняется именно этим.
Чтобы это контролировать, есть cache в init:
| Значение | Что делает |
|---|---|
default | следует Cache-Control/ETag — нормальный браузерный кэш |
no-store | не читать и не писать кэш, всегда в сеть |
reload | всегда в сеть, но ответ можно закэшировать |
no-cache | кэш можно, но перед отдачей — валидировать (условный GET) |
force-cache | из кэша, даже просроченный; в сеть — только если вообще нет |
only-if-cached | в сеть не ходить никогда; если нет — 504 |
Для API-чтений, где «свежесть» важна (профиль, баланс, счётчик), ставят cache: 'no-store' в init и Cache-Control: no-store на сервере — иначе пользователь будет видеть «старые» данные после logout/login. Для «статичных» списков, наоборот, ставят cache: 'default' и ждут протухания.
18. Rate limit и Retry-After
Хорошие API отдают 429 Too Many Requests и заголовок Retry-After. Он может быть:
Retry-After: 30 // 30 секунд
Retry-After: Wed, 21 Oct 2026 07:28:00 GMT // HTTP-date
Клиент обязан не ретраить мгновенно, а ждать ровно столько, сколько сказано. Простейший helper:
function retryDelayMs(res) {
const v = res.headers.get('Retry-After');
if (!v) return null;
if (/^\d+$/.test(v)) return Number(v) * 1000;
const d = new Date(v);
return isNaN(d) ? null : Math.max(0, d.getTime() - Date.now());
}
19. Пагинация: offset vs cursor
- Offset/limit (
?offset=200&limit=50) — прост, но может пропустить/дублировать элементы, если список «подпрыгивает» между запросами (новый элемент вставлен в начало). - Cursor-based (
?cursor=opaqueToken&limit=50) — сервер отдаёт «токан, после которого продолжать». Стабильнее при вставках и чаще нужен для ленты, топ-чартов, стриминга.
{
"items": [ /* ... */ ],
"next_cursor": "eyJvZmZzZXQiOjUwfQ",
"has_more": true
}
let cursor = null;
const items = [];
do {
const url = new URL('https://api.example.test/feed');
url.search = new URLSearchParams({ limit: '50', ...(cursor ? { cursor } : {}) }).toString();
const page = await api(url);
items.push(...page.items);
cursor = page.next_cursor;
} while (cursor);
20. Идемпотентный retry: экспоненциальный backoff + jitter
Повторять запрос стоит только на «идемпотентных» методах (GET/HEAD/PUT/DELETE, а также POST с Idempotency-Key) и только при 5xx/network error/timeout. Для 4xx retry не имеет смысла — это баг клиента.
async function withRetry(fn, { retries = 3, base = 300, factor = 2 } = {}) {
let attempt = 0;
for (;;) {
try { return await fn(); }
catch (err) {
const retriable =
err.name === 'TimeoutError' ||
(err.status >= 500 && err.status < 600) ||
err.name === 'TypeError'; // «Failed to fetch» — сеть упала
if (!retriable || attempt >= retries) throw err;
const delay = Math.min(20000, base * factor ** attempt);
const jitter = delay * (0.5 + Math.random() * 0.5); // без jitter — «thundering herd»
await new Promise(r => setTimeout(r, jitter));
attempt++;
}
}
}
Jitter критичен: если 100 клиентов одновременно упадут на один 503 и поставят одинаковый backoff, они вернутся синхронно и «положат» сервер второй раз. Рандомизированный интервал размазывает повторные волны.
Часть VI. Отображение: гонки, состояния и XSS
21. Гонка ответов: почему «await + setState» ломается
Представьте классический «живой поиск»: пользователь печатает «р», «ра», «раз». Отправлены три запроса, пришли в другом порядке: «ра» быстрее, «раз» — позже. Без защиты UI покажет список для «ра», когда поле уже содержит «раз». Это не гипотеза — это баг №1 в списках с фильтрами.
Два корректных способа решить:
AbortControllerна «смену входа» (см. §15). При каждом новом запросе предыдущий отменяется, и «позднего» ответа физически не будет.- Монотонный
requestId. Если вы не можете отменить (например, запрос уже ушёл и сервер начал работу), отбрасывайте «поздние» по id:
let reqId = 0;
async function load(q) {
const my = ++reqId;
const res = await api(`/search?q=${encodeURIComponent(q)}`);
if (my !== reqId) return; // меня опередил новый запрос — молча отбрасываем
render(res);
}
Первый способ — правильнее (экономит трафик), второй — дополняет, если отмена недоступна.
22. Состояния UI — это автомат, а не три флага
isLoading, error, data как независимые boolean дают восемь комбинаций, из которых пять противоречивы (error && data && !loading, !loading && !error && data === null и т.п.). Это обязательно взорвётся: UI покажет «нет данных» вместо «ошибка», или «крутилку» на пустом экране.
Канон — один тег-объект:
let state = { kind: 'idle' };
async function load() {
state = { kind: 'loading' }; render(state);
try {
const items = await api('/items');
state = items.length
? { kind: 'success', items }
: { kind: 'empty' };
} catch (err) {
state = { kind: 'error', message: err.message };
}
render(state);
}
function render(s) {
switch (s.kind) {
case 'idle': showPrompt(); break;
case 'loading': showSkeleton(); break;
case 'success': showList(s.items); break;
case 'empty': showEmpty(); break;
case 'error': showError(s.message); break;
}
}
Такая форма совместима с «экран загрузки — скелетон», «пусто — другое сообщение», «ошибка — свой CTA» без логических дыр и без if (loading && !error && data) .... Если вы пишете React-компонент — это тот же паттерн, только вместо let state — useState/reducer.
23. Debounce — для чего и сколько
Если запрос идёт «на каждое нажатие», вы (а) жжёте трафик, (б) провоцируете rate-limit, (в) создаёте race. Debounce «ждём паузу 200–300 мс и только тогда отправляем» — минимальная гигиена для поиска:
function debounce(fn, wait = 250) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), wait);
};
}
input.addEventListener('input', debounce((e) => loadUsers(e.target.value)));
Плюс AbortController на каждую отправку внутри — вместе они и «не спамим», и «не прыгаем».
24. XSS: рендер полученного — только через API DOM, а не строкой
Данные с сервера данные, а не HTML. Если вы делаете container.innerHTML = '<li>' + user.name + '</li>', а user.name содержит <img src=x onerror=alert(1)> — вы получили DOM XSS из API, которое вы, возможно, сами и защищали CSP. Канонический рендер:
function renderItem(item) {
const li = document.createElement('li');
const title = document.createElement('a');
title.textContent = item.title; // текстовый узел, не HTML
title.href = item.url; // href тоже фильтруем по схеме
if (!/^https?:/i.test(title.href)) title.removeAttribute('href');
const meta = document.createElement('span');
meta.className = 'meta';
meta.textContent = `${item.author} · ${new Date(item.published_at).toLocaleDateString()}`;
li.append(title, ' ', meta);
return li;
}
function renderList(items) {
const ul = document.querySelector('#list');
ul.replaceChildren(...items.map(renderItem)); // один DOM-оператор вместо цикла insertAdjacentHTML
}
Что важно:
textContentэкранирует автоматически;innerText— тоже, но он «считает» layout и заметно медленнее.replaceChildren(...nodes)— современный способ заменить всё содержимое одним вызовом.href/src/action— фильтруйте по схеме:javascript:,data:иvbscript:вhrefэто тот же XSS.- Если HTML от сервера неизбежен (CMS-поле с разметкой) — санитайзер обязателен (DOMPurify) и CSP — тоже. Не рендерите «сырой» HTML из API в
innerHTMLдаже «внутренних» сервисов: скомпрометированный редактор = XSS на вашем фронте.
Часть VII. Практика: полный цикл «лента с фильтрами»
Собираем всё вместе: живой поиск с debounce, отмена предыдущего запроса, таймаут, retry с backoff, state-машина, XSS-safe рендер. Это ~50 строк ванильного JS, и он заменяет собой «react-query lite» для большинства списков.
const listEl = document.querySelector('#posts-list');
const statusEl = document.querySelector('#posts-status');
const input = document.querySelector('#q');
let currentAc = null;
let state = { kind: 'idle' };
let debounceTimer;
async function loadPosts(query, { signal } = {}) {
const url = new URL('/posts', 'https://api.example.test/');
if (query) url.searchParams.set('q', query);
const res = await fetch(url, {
headers: { 'Accept': 'application/json' },
signal,
});
if (!res.ok) {
const err = new Error('HTTP ' + res.status);
err.status = res.status;
throw err;
}
return res.json();
}
function withRetry(fn, signal, { retries = 2, base = 300 } = {}) {
return (async () => {
let attempt = 0;
for (;;) {
try { return await fn(); }
catch (err) {
if (err.name === 'AbortError') throw err;
const retriable = err.status >= 500 || err.name === 'TypeError' || err.name === 'TimeoutError';
if (!retriable || attempt >= retries) throw err;
await new Promise((r, rej) => {
const delay = base * 2 ** attempt * (0.5 + Math.random() * 0.5);
const t = setTimeout(r, delay);
signal?.addEventListener('abort', () => { clearTimeout(t); rej(err); }, { once: true });
});
attempt++;
}
}
})();
}
function render() {
switch (state.kind) {
case 'loading': statusEl.textContent = 'Загрузка…'; listEl.replaceChildren(); return;
case 'error': statusEl.textContent = 'Ошибка: ' + state.message; return;
case 'empty': statusEl.textContent = 'Ничего не найдено'; listEl.replaceChildren(); return;
case 'success': {
statusEl.textContent = `Найдено: ${state.items.length}`;
listEl.replaceChildren(...state.items.map(post => {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = post.url;
a.textContent = post.title;
if (!/^https?:/i.test(a.href)) a.removeAttribute('href');
const by = document.createElement('span');
by.className = 'by';
by.textContent = ' — ' + post.author;
li.append(a, by);
return li;
}));
return;
}
}
}
async function search(query) {
currentAc?.abort(); // отменяем предыдущий в-полёт
const ac = new AbortController();
currentAc = ac;
const signal = AbortSignal.any([ac.signal, AbortSignal.timeout(8000)]);
state = { kind: 'loading' }; render();
try {
const items = await withRetry(() => loadPosts(query, { signal }), signal);
if (ac.signal.aborted) return; // страховка на «уже отменили»
state = items.length ? { kind: 'success', items } : { kind: 'empty' };
} catch (err) {
if (err.name === 'AbortError') return; // отменено нами — штатно, молча
state = { kind: 'error', message: err.message };
}
render();
}
input.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => search(e.target.value.trim()), 250);
});
// Первый вход
search('');
// При уходе со страницы — гарантированно отменяем:
window.addEventListener('pagehide', () => currentAc?.abort());
Что здесь покрывается:
- Отмена предыдущего запроса при новом вводе —
currentAc?.abort()(§15, §21). - Таймаут через
AbortSignal.timeout(8000)в комбинации с ручным abort (§14). - Retry с backoff и jitter, только на ретраибельных кодах и с «умиранием» при abort (§20).
- State-машина — четыре взаимоисключающих состояния, без «и loading, и error» (§22).
- XSS-safe рендер —
textContent/hrefс проверкой схемы,replaceChildrenодним проходом (§24). - Отмена при уходе со страницы через
pagehide— чтобы «висящий» запрос не продолжал грузить сеть после перехода (§15).
Инструмент под задачу
Образцы ответа с REST-эндпоинта (JSON-пагинация, ошибки, поля) удобно «пощупать» конвертером формата до того, как писать парсер. Попробуйте инструмент прямо здесь, на своих данных:
Чек-лист: готовность и грабли
- Два
await:await res+await res.json(). Если вы получили «Response без поля» — вы забыли второй. - Тело читается один раз. Если нужно и логировать, и отдать наружу —
await res.text()→JSON.parseсами. fetchреджектится только на сетевой ошибке. Проверяйтеres.okвручную, иначе404превратится в «успешный рендер с undefined».204не имеет тела.res.json()на204бросит. Проверяйте статус перед чтением.Content-TypeприFormDataне ставят вручную. Иначе ломается boundary и сервер видит пустое тело.- Query-параметры — только через
URL/URLSearchParams. Прямая конкатенация с пользовательской строкой — вектор. - Отмена — это
AbortController.signal, а неreturnв then. «Перестать await-ить» не обрывает TCP. - Не путайте
AbortErrorиTimeoutError. Первый — вы сами отменили (молча), второй — сервер медленный (retry/баннер). AbortSignal.any([...])— стандарт, используйте вместо самодельного «mux» на слушателях.- Кэш может сделать запрос «невидимым».
response.status 200 из диска≠ «запрос прошёл в сеть». Если нужен «всегда свежий» —cache: 'no-store'в init. - CORS-ошибка — это
TypeError: Failed to fetchбез деталей. Статус недоступен принципиально. Диагностика — в DevTools Console / Network, а не в try/catch. - Не ретрайте
4xx. Только5xx, сетевую ошибку иTimeoutError. - Backoff без jitter — «thundering herd». Всегда
delay * (0.5 + Math.random() * 0.5). Retry-After— обязательное чтение.429без паузы = ещё больший бан.- Гонку решает отмена/
requestIdна входе, а не проверка на выходе. «Проверить вthen, что пользователь всё ещё смотрит эту страницу» — тоже работает, но требует аккуратности. - UI-состояние — тег-объект, а не три boolean. Иначе 5 из 8 комбинаций дадут противоречивый экран.
- Данные с сервера — не HTML в
innerHTML. ТолькоtextContent/setAttribute; дляhref/srcфильтруйте схемы. pagehide/уход из компонента — отменяйте висящие запросы. Иначе они доживают до конца вкладки.
Материал носит образовательно-практический характер. Синтаксис и семантика соответствуют актуальному стандарту Fetch (
fetch,Request,Response,AbortController,AbortSignal.timeout/any,cache-режимы) и конвенциям REST/HTTP (RFC 9110/9111, семантика методов и кодов). Поведение конкретных фич в старых мобильных браузерах сверяйте по caniuse и MDN.
👤 Зарегистрируйтесь, чтобы добавить материал в закладки.
