Unexpected token в JSON: почему API возвращает HTML вместо данных
Сообщение Unexpected token '<' при разборе JSON часто появляется там, где пользователь ждёт список товаров, результат сохранения или данные личного кабинета. Интерфейс может зависнуть, хотя сервер уже вернул ответ. Проблема возникает потому, что клиент пытается прочитать полученное содержимое как JSON, а внутри оказывается другой текст — например, HTML страницы входа.
Исправление начинается с проверки ответа на конкретный запрос. Нужно установить его адрес, статус, перенаправления, тип и фактическое содержимое. Затем команда исправляет источник неправильного ответа и обработку результата в приложении. Замена сообщения об ошибке или повторный вызов парсера не восстановит ожидаемые данные.
Что означает Unexpected token и почему важен символ
Метод Response.json() читает тело ответа и разбирает его как JSON. В документации MDN невозможность такого разбора описана как SyntaxError. Символ в сообщении помогает понять, где парсер встретил неожиданный текст, но сам по себе не устанавливает причину его появления.
Частая причина ошибки Unexpected token при разборе JSON — неожиданный HTML: первый значимый символ HTML-разметки — знак <. Проверьте начало фактического ответа. Там может быть <!doctype html>, заголовок страницы входа или шаблон ошибки. Формулировка исключения отличается между окружениями, поэтому расследование нельзя строить на точном совпадении строки сообщения.
Если ответ начинается с JSON, ищите другое нарушение: обрезанный документ, лишний текст перед объектом, неправильное экранирование. Если разбор прошёл, но дальше не найдено поле, это уже иной этап. Отделение этих случаев экономит время: разработчик проверяет именно тот участок, где данные перестали соответствовать ожиданию.
Сначала посмотрите ответ в Network
Откройте инструменты разработчика до воспроизведения ошибки. В Network найдите запрос, после которого интерфейс перестал работать. Укажите время и действие пользователя, чтобы не спутать проблемный API с аналитикой или загрузкой изображения. При переходах сохраните журнал между загрузками страницы.
- Проверьте полный адрес, домен, метод и параметры нужного запроса.
- Запишите HTTP-статус и окончательный URL после переходов.
- Посмотрите заголовок ответа Content-Type.
- Изучите фактическое тело в Response, начиная с первых значимых символов.
- Сопоставьте результат с тем, что обещает контракт этого API.
Запрос и ответ имеют разные заголовки и тела. Установка Content-Type: application/json в исходящем запросе описывает отправляемые данные; она не превращает HTML, полученный от сервера, в JSON. Здесь исследуется именно содержимое ответа, на котором упал клиентский разбор.
Для передачи наблюдений достаточно обезличенных технических деталей. Не копируйте целиком страницу аккаунта или ответ со списком клиентов в общий чат. Оставьте статус, маршрут, тип ответа, безопасный идентификатор и минимальный фрагмент, показывающий характер подмены.
Проверьте маршрут API и попадание в приложение сайта
Одна из возможных причин — запрос отправлен не по тому адресу. Относительный путь может разрешиться относительно неожиданного раздела, переменная окружения — указывать на другой домен, а новая версия клиента — обращаться к маршруту, которого ещё нет на сервере. Проверяйте вычисленный адрес в браузере, а не только строку в исходном коде.
В учебном примере клиент ожидает /api/profile, но отправляет запрос на /account/api/profile. Сервер не находит обработчик и возвращает главную HTML-страницу приложения по общему правилу маршрутизации. Она может иметь статус 200: загрузка оболочки сайта успешна, но контракт API нарушен.
Сравните правило для API и правило возврата HTML для пользовательских страниц. Неизвестный API-маршрут должен обрабатываться предсказуемо для клиента. Исправление может потребоваться в базовом URL, проксировании или порядке маршрутов; простая замена заголовка у HTML-страницы проблему не устранит.
Проверьте также метод. Наличие обработчика GET ещё не подтверждает, что POST по тому же пути попадёт в нужную логику. В отчёте сохраняйте пару «метод и маршрут», чтобы подрядчик воспроизвёл тот же запрос.
Выясните, не оказался ли ответ страницей входа
Истёкшая сессия может привести к перенаправлению запроса на HTML-страницу авторизации. Клиент ожидает профиль, получает успешно загруженную форму входа и пытается разобрать её как JSON. Важно увидеть всю цепочку, иначе финальный статус 200 создаёт ложное впечатление исправного API.
Свойство response.redirected показывает наличие перенаправления, а response.url отражает итоговый адрес. Как поясняет MDN о перенаправленных ответах, проверка этого свойства происходит уже после перехода. Если контракт запрещает перенаправления, соответствующую политику задают при вызове fetch, а не используют позднюю проверку как предотвращение перехода.
Проверьте сценарии действующей и истёкшей сессии, а также пользователя без доступа к объекту. Согласуйте машинно читаемый ответ API и понятное действие интерфейса: обновить авторизацию, предложить вход или показать отказ. Не отправляйте посетителя в бесконечный цикл повторных запросов.
Если запрос идёт между разными origin, отдельно исследуйте настройки credentials и CORS. Сам факт наличия cookies в браузере не подтверждает, что конкретный запрос передал нужную сессию и получил доступный для JavaScript ответ.
Найдите узел, который вернул HTML ошибки
HTML вместо JSON может сформировать не приложение, а промежуточный компонент. Например, прокси возвращает страницу недоступности upstream, защитный шлюз — проверку посетителя, а площадка размещения — страницу обслуживания. Клиент видит сетевой ответ, но он создан другим участником цепочки.
Сравните публичный путь и разрешённую диагностическую проверку приложения за посредниками. Сохраняйте тот же маршрут, метод, условия авторизации и тестовые данные. Доступ к внутреннему узлу выполняют предусмотренным способом; открывать его всем посетителям ради сравнения не требуется.
Ищите согласованные свидетельства: время, идентификатор запроса, ответ upstream и запись нужного компонента в журнале. Логотип на HTML-странице может подсказать направление, но не заменяет подтверждение. Одинаковый шаблон ошибки иногда используется несколькими слоями.
Исправляйте причину отказа и правила ответа на API-маршруте. Клиенту всё равно нужна обработка неожиданного формата, поскольку недоступность посредника возможна и после исправления. Однако обработчик не должен объявлять успешной операцию, для которой получена только страница ошибки.
Почему response.ok не гарантирует JSON
Согласно руководству MDN по Fetch, fetch не отклоняет promise только потому, что сервер вернул HTTP 404 или 500. Клиент получает объект Response и должен оценить статус. Свойство ok означает диапазон 200–299, а не проверку типа, синтаксиса или структуры данных.
В учебной ситуации возможны четыре разных результата: 200 с нужным JSON, 200 с HTML оболочки сайта, 401 с JSON описанием отказа и 502 с HTML от прокси. Проверка одного ok разделит их лишь на успешные и неуспешные HTTP-статусы. Для обработки содержимого нужны дополнительные условия.
Сохраните исходный статус при классификации ошибки. Если 502 сопровождается некорректным для клиента телом, сообщение «не удалось разобрать JSON» не должно скрывать 502. Команде нужны обе характеристики: результат HTTP и причина, по которой содержимое нельзя использовать.
Не возвращайте пустой объект из любого catch ради продолжения работы интерфейса. Такое поведение может показать пустую корзину или отсутствие уведомлений вместо признака сбоя. Выберите явное состояние ошибки, соответствующее операции.
Для изменяющего запроса ошибка разбора ответа не доказывает, что действие не состоялось. Сохранение могло завершиться до формирования неправильного ответа. Поэтому автоматическую повторную отправку нельзя добавлять как универсальное исправление. Сначала предусмотрите способ проверить состояние операции и правила допустимого повтора. Для чтения списка и создания заказа эти правила будут различаться.
Обработайте 204 и пустое тело по контракту
Статус 204 No Content означает ответ без содержимого. Если API договорился подтверждать действие таким способом, вызывать JSON-парсер для этого ответа не нужно. Клиент должен распознать статус и выполнить предусмотренное действие без ожидания объекта.
Пустой ответ с кодом 200 — другой случай. Если контракт обещает JSON, отсутствие тела нарушает ожидание. Нельзя автоматически приравнивать пустую строку к null или {}: это разные значения с разным смыслом. Пробельная строка также не является полноценным JSON-документом.
Уточните поведение для операций, которые иногда возвращают объект, а иногда только подтверждение. В учебном контракте ниже 200 содержит профиль, а 204 означает согласованное отсутствие представления. Это локальное правило примера, не универсальная трактовка всех API.
Если сообщение говорит о неожиданном конце JSON, проверьте, действительно ли тело пустое или документ оборван. Сравните данные приложения и доступную сетевую запись, учитывая, что отсутствие сохранённого тела в инструменте диагностики само по себе ещё не доказывает пустой сетевой ответ.
Учитывайте application/problem+json и типы с суффиксом +json
Проверка типа не должна сводиться к сравнению всей строки заголовка с единственным значением без учёта параметров. Например, в application/json; charset=utf-8 медиатип выделяют отдельно. При этом допустимые форматы конкретного API всё равно нужно явно согласовать.
RFC 6839 описывает суффикс +json для представлений, использующих JSON. Это позволяет распознать общий формат, но не делает разные прикладные типы взаимозаменяемыми. Неизвестный объект нельзя передавать в обработчик профиля только потому, что он успешно разобран.
RFC 9457 определяет application/problem+json для машиночитаемого описания проблемы. Такой ответ не является HTML и не должен отвергаться как «не JSON» из-за отсутствия точного совпадения с application/json. Но описание ошибки также не является успешными данными страницы.
Согласуйте отдельные наборы типов для успеха и ошибки, а затем проверяйте нужную структуру. В примере используется условный тип профиля application/vnd.demo.profile+json. Он иллюстрирует договорённость проекта, а не рекомендацию принимать любой неизвестный тип с похожим окончанием.
Оценка заголовка и проверка содержимого дополняют друг друга. Если посредник ошибочно пометил HTML как application/json, проверка медиатипа пройдёт, а разбор завершится исключением. Если пришёл корректный JSON с неподходящим типом, выясните нарушение контракта отдельно. Подмена всех ответов одним заголовком только скроет различия между этими ситуациями.
Читайте тело один раз и возвращайте безопасную категорию ошибки
Тело Response является потоком. После чтения через json() нельзя просто вызвать text() на том же объекте, чтобы посмотреть исходник. Для небольшого ответа удобно один раз получить текст и затем разобрать его. Другой вариант требует заранее созданной копии ответа; выбор зависит от задачи и размера данных.
Ниже учебный обработчик уже полученного Response. Он принимает 200 с объектом профиля и строкой displayName, либо 204 без представления. HTTP-ошибка остаётся ошибкой даже с корректным JSON. В исключение попадают фиксированная категория и статус, без тела ответа и текста исключения JSON.parse.
async function readProfileResponse(response) {
const status = response.status;
function fail(kind) {
const error = new Error(kind);
error.kind = kind;
error.status = status;
throw error;
}
if (status === 204) return null;
const mime = (response.headers.get("content-type") || "")
.split(";")[0].trim().toLowerCase();
const successTypes = new Set([
"application/json",
"application/vnd.demo.profile+json"
]);
const knownJson = successTypes.has(mime) ||
mime === "application/problem+json";
if (!knownJson) {
fail(response.ok ? "unexpected_media_type" : "http_unexpected_media_type");
}
let text;
try {
text = await response.text();
} catch (error) {
fail(error.name === "AbortError" ? "aborted" : "body_read_failed");
}
if (!text.trim()) fail("empty_body");
let data;
try {
data = JSON.parse(text);
} catch {
fail("invalid_json");
}
if (!response.ok) fail("http_error");
if (status !== 200 || !successTypes.has(mime)) fail("unexpected_contract");
if (!data || typeof data !== "object" || Array.isArray(data) ||
typeof data.displayName !== "string") fail("schema_error");
return data;
}
Функцию вызывают после получения Response от fetch. Отказ самого fetch обрабатывается отдельно; этот пример не превращает сетевую ошибку в ошибку JSON. В приложении также задают пределы ожидания и размера ответа. Произвольные поля успешного объекта не следует автоматически отправлять в телеметрию.
Внешний обработчик выбирает по категории понятное сообщение посетителю и оставляет безопасные технические признаки для команды. Он не вставляет HTML полученной страницы ошибки в интерфейс. Если требуется показать детали application/problem+json, сначала проверяют их структуру и разрешённые поля. Приведённая функция намеренно не использует содержимое ошибки как готовый текст для публикации.
Разделите синтаксис, сеть, CORS, декодирование и схему
Одно сообщение «API недоступен» плохо помогает расследованию. Сначала определите, получен ли доступный ответ и на каком этапе возникло исключение. В документации Response.json отдельно перечислены ошибки разбора JSON, отмена и проблемы чтения или декодирования, включая неправильный Content-Encoding.
| Наблюдение | Что проверять дальше |
|---|---|
| Fetch отклонён до получения доступного Response | Сеть, политики браузера, CORS, отмену и параметры запроса |
| HTTP 4xx или 5xx | Причину отказа и формат описания ошибки |
| HTML вместо ожидаемого JSON | Маршрут, перенаправление, посредника и авторизацию |
| Тело не удаётся прочитать или декодировать | Состояние потока, отмену, Content-Encoding и доставку |
| Текст не разбирается как JSON | Фактический синтаксис, обрыв и лишний текст |
| JSON разобран, но полей не хватает | Схему, версию контракта и соответствие операции |
Ошибка CORS не означает, что сервер прислал неправильный JSON: браузер мог не разрешить коду доступ к ответу. Режим no-cors не решает задачу чтения JSON из чужого origin, поскольку такой ответ становится непрозрачным для клиента. Проверяйте разрешения на нужном маршруте.
После успешного разбора допустимыми JSON-значениями могут оказаться массив, строка или null. Если экран ожидает объект с определёнными полями, требуется проверка схемы. Не называйте её провал синтаксической ошибкой JSON.
Соберите доказательства и исправьте нужную границу
Рассмотрим учебный сбой: GET /api/profile после истечения сессии перенаправляется на /login, возвращает 200 и text/html. Клиент вызывает JSON.parse и получает ошибку на символе <. Здесь подтверждены переход, тип и содержимое. Предположение о неисправности базы данных из этих наблюдений не следует.
Команда меняет обработку неавторизованного API-запроса на согласованный машинно читаемый ответ и добавляет понятное состояние входа в интерфейсе. Проверка выполняется с действующей и истёкшей сессией. Отдельно подтверждается, что успешный профиль по-прежнему имеет нужную структуру.
В диагностической записи оставьте время с часовым поясом, метод, безопасный маршрут, статус, медиатип, наличие перехода, категорию ошибки и согласованный request ID. Полный URL может содержать секреты, а тело — персональные данные. Для поиска причины обычно важнее связать события, чем собрать всё содержимое.
Если проблема относится к маршрутизации или прокси, назначьте проверку именно этой конфигурации. Изменение клиентского catch повышает понятность ошибки, но не подтверждает, что сервер снова выдаёт ожидаемый результат.
Сравнивайте успешную и неудачную попытки одного сценария. Укажите версию клиента, время проверки и состояние сессии. Если изменились одновременно адрес API, пользователь и серверная версия, различие ответов ещё не показывает, какое именно изменение помогло. Для принятия исправления нужна воспроизводимая связь между причиной, правкой и результатом.
Как убедиться, что ошибка исправлена
Проверяйте не только исчезновение строки Unexpected token из консоли. Приёмка должна подтвердить правильное состояние интерфейса и сохранение различий между видами ответа. Для учебного контракта полезен следующий набор:
- 200 с допустимым типом и нужной схемой показывает профиль.
- 200 с HTML и HTML под ошибочным JSON-заголовком дают понятный отказ.
- 204 обрабатывается без попытки разобрать отсутствующее тело.
- 200 с пустым или повреждённым JSON не превращается в успешный пустой объект.
- 401 с application/problem+json приводит к согласованному сценарию доступа.
- 502 с HTML сохраняет HTTP-статус в диагностике.
- Сетевой отказ, отмена и ошибка схемы различаются на нужном уровне.
- Повторное чтение тела отсутствует, а журнал не содержит секретных ответов.
Сохраните такие случаи как проверку контракта при следующих релизах. Новое правило перенаправления или общая HTML-страница ошибок способны снова изменить ответ API, даже если сам обработчик данных не менялся. Согласованные проверки позволяют обнаружить это до обращений пользователей.
Дополните диагностику внешней проверкой HTTP-доступности через SiteGuard. Выберите важные адреса сайта, а правильность JSON, авторизацию и поведение интерфейса проверяйте вместе с командой по контракту API.