Подключаем внешний API

проект≈ 7 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Пока приложение работает на своих выдуманных данных, оно живёт в тепличных условиях. Настоящий API отвечает с задержкой, иногда отвечает ошибкой, а иногда не отвечает вовсе — и интерфейс обязан пережить все три сценария, не показав пользователю пустой экран.

Разделы темы:·

Запрос к чужому серверу в современном браузере выглядит короче, чем кажется: const res = await fetch(url) — и у нас есть ответ. Но ответ — это ещё не данные. fetch завершается, как только пришли заголовки: статус известен, а тело может продолжать загружаться. Поэтому за первым await почти всегда следует второй: const data = await res.json().

В песочнице учебника сети нет, поэтому дальше вместо fetch будет заглушка с тем же интерфейсом — объект с ok, status и методом json(). Функция, написанная на такой заглушке, без единой правки работает и с настоящим fetch: важна форма ответа, а не то, кто его выдал.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Всё, что попало в код страницы, видно пользователю. Ключ платного API, вшитый во фронтенд, — это уже утёкший ключ: браузер должен ходить на ваш собственный сервер, а тот — на чужой API.

Про то, что 404 — не ошибка, вы уже знаете: fetch не считает ошибкой ни 404, ни 500, потому что с его точки зрения запрос удался — сервер ответил, ответ доставлен. Промис отклоняется только тогда, когда ответа не было вовсе: нет сети, сорвалось соединение, запрос отменили. Поэтому try/catch вокруг fetch ловит меньше, чем вы думаете.

Лечится это одной строкой: проверить res.ok (он true для статусов 200–299) и превратить плохой статус в исключение самому. Дальше по коду будет ровно один путь для всех бед — catch.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Ответ приходит потоком: сначала статус и заголовки, потом байты тела. fetch отдаёт управление на первом же этапе, чтобы вы могли решить — читать тело или выбросить ответ, не скачивая мегабайты.

Тело можно прочитать только один раз и только одним способом: res.json(), res.text(), res.blob(). Второй вызов на том же ответе бросит ошибку — поток уже вычерпан.

Пока идёт запрос, у экрана нет данных — и это нормальное, а не аварийное состояние. Удобно держать в приложении одно поле status с тремя значениями: loading, error, ready. Тогда разметка становится честной функцией от состояния, а не набором разбросанных флагов вроде isLoading и hasError, которые рано или поздно окажутся включены одновременно.

Правило простое: перед запросом ставим loading, в try получаем данные и ставим ready, в catch — error с текстом. Ни одна ветка не остаётся без записи состояния, иначе спиннер останется крутиться навсегда.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Отмену запроса вы видели в статье про fetch; в проекте она из удобства становится обязательной — ниже видно, почему. Устройство напомним: создаём const controller = new AbortController(), кладём controller.signal в настройки запроса, второй аргумент fetch — fetch(url, { signal: controller.signal }) — и в любой момент зовём controller.abort(). Промис запроса тут же отклонится ошибкой с именем AbortError.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Важная деталь: отмена приходит в тот же catch, что и настоящие сбои. Но пользователю нечего показывать — он сам ушёл со страницы или начал новый поиск. Поэтому в обработчике ошибку по имени отделяют от остальных и молча выходят.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Пользователь печатает в поиске «Дуб», потом «Дубай». Уходят два запроса, и никто не обещал, что ответы вернутся в том же порядке: первый мог застрять в пути и прийти вторым. Экран покажет результаты по «Дуб», хотя в поле написано «Дубай». Ошибки нет, данные настоящие — просто устаревшие.

Два лекарства, и они дополняют друг друга. Первое — AbortController: перед новым запросом отменить предыдущий, чтобы он не тратил сеть. Второе — сторож на стороне кода: нумеровать запросы и применять только ответ с последним номером. Второе нужно даже с отменой, потому что ответ мог уже прилететь в момент отмены.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

У fetch нет таймаута по умолчанию: запрос может висеть, пока браузер не сдастся сам. Ставьте предел явно — AbortSignal.timeout(5000) отменит запрос через пять секунд. Попадёте вы в тот же catch, что и при отмене, но имя у ошибки другое — TimeoutError, а не AbortError: молчать про таймаут как про отмену нельзя, человек ничего не отменял. Пять секунд молчания для пользователя — уже вечность, лучше показать ему повтор.

Что сломалосьКак это видно в кодеЧто показать пользователю
Нет сетипромис fetch отклонён, TypeError«Нет связи» и кнопка «Повторить»
Сервер вернул 500res.ok равен false, статус 500«Сервис недоступен, попробуйте позже»
Ответ не JSONres.json() бросает SyntaxError«Не удалось прочитать ответ», подробности — в лог
Ответ слишком долгийсработал таймаут, TimeoutError«Долго не отвечает» и повтор
Запрос отменили самиAbortErrorничего: это не ошибка пользователя

Если у приложения есть чем прикрыться — прошлые данные, кеш, разумные значения по умолчанию — покажите их, но честно пометьте как устаревшие. Пустой экран хуже старой цифры с подписью «данные могли устареть».

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Бить в упавший сервер каждые сто миллисекунд — верный способ добить его. Пауза между попытками удваивается, попыток обычно три, и повторять стоит только сетевые сбои и 5xx: на 404 повтор ничего не изменит.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

  • fetch даёт ответ в два шага: сначала await fetch(url) (статус и заголовки), потом await res.json() (тело).
  • HTTP-ошибка для fetch не ошибка: проверяйте res.ok и бросайте исключение сами, чтобы все беды сходились в одном catch.
  • Держите одно поле состояния с тремя значениями — loading, error, ready — и рисуйте экран как функцию от него.
  • AbortController отменяет запрос, а нумерация запросов защищает от гонки: применяем только самый свежий ответ.
  • У fetch нет таймаута по умолчанию — задайте его явно, а на случай недоступного API держите повтор и запасные данные с пометкой «устарели».

1/3Сервер ответил статусом 500. Что сделает await fetch(url)?

2/3Пользователь быстро правит строку поиска, ответы приходят вразнобой. Что окажется на экране, если ничего не предпринять?

3/3Что произойдёт с промисом fetch после вызова controller.abort()?

Загрузка с честными состояниями

Напишите loadUser(request): она получает промис ответа (в бою это был бы fetch(url)), проверяет res.ok, читает JSON и возвращает объект состояния — { status: "ready", data } при успехе и { status: "error", error } с текстом вида HTTP 500 при любой беде. Функция не должна бросать исключений наружу.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

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

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Заметьте: catch ловит и сорванную сеть, и наш собственный throw — вызывающему коду не нужно знать, что именно пошло не так.

Сторож против устаревших ответов

Сделайте фабрику createLatest(): она возвращает функцию start(), каждый вызов которой выдаёт проверку isActual(). isActual() должна вернуть true только для самого свежего вызова start() и false для всех предыдущих.

Ожидаемое поведение: сделали const a = start(), потом const b = start(). Тогда a() — false, b() — true. Никаких таймеров: вся логика на одном счётчике в замыкании.

Счётчик живёт в замыкании фабрики, а каждая проверка помнит свой номер.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В настоящем коде isActual() вызывают сразу после await: если она вернула false, ответ просто выбрасывают и состояние не трогают.