Fetch API и работа с REST: от запроса до отображения

Эта статья — про полный цикл: как устроен 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:

ЧтоXHRfetch
Отменаxhr.abort()AbortController.signal
Прогресс загрузкиonprogress из коробкитолько через response.body ReadableStream (загрузка тела запроса пока вне стандарта)
Кросс-доменные кукиxhr.withCredentials = truecredentials: '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();
}

Три ошибки, которые ловятся ревью:

  1. body: data вместо JSON.stringify(data). В fetch нет «магии сериализации»: если передать объект, браузер прочитает его как [object Object] и уйдёт в сеть мусор.
  2. Забыть Content-Type. Многие API вернут 415 Unsupported Media Type; другие молча примут text/plain и не распарсят JSON.
  3. Забыть 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(), он упадёт
304Not Modifiedбраузер обслуживает прозрачно: вам придёт status 200 из кэша, если вы его не отключали; вручную ловят редко
400Плохой запросподсветка формы/ошибка клиента, retry — только после правки
401Не аутентифицированобновить токен / уйти на login
403Нет правне редиректить, показать 403-UI
404Нет ресурсапоказать «страница не найдена», не retry
409Конфликт«уже есть», предложить слияние / перечитать
429Rate 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 в списках с фильтрами.

Два корректных способа решить:

  1. AbortController на «смену входа» (см. §15). При каждом новом запросе предыдущий отменяется, и «позднего» ответа физически не будет.
  2. Монотонный 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-пагинация, ошибки, поля) удобно «пощупать» конвертером формата до того, как писать парсер. Попробуйте инструмент прямо здесь, на своих данных:

Конвертер JSON
Гость
Токены: 0 / 50 000
Гостевой лимит — 50 000 токенов. Зарегистрируйтесь, для пользователей лимит + 200 000 токенов.

Чек-лист: готовность и грабли

  • Два 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.

👤 Зарегистрируйтесь, чтобы добавить материал в закладки.