
Что-то вызывает JSON.parse, напрямую или через res.json(), и в консоли появляется одно из
двух:
SyntaxError: Unexpected end of JSON input
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
Почти все читают это как претензию к своему JSON и идут смотреть свой JSON. JSON почти никогда не является причиной. Обычно никакого JSON нет вовсе, и парсер рассказывает вам про первое, что он не смог использовать.
Это сообщения, которые печатает Chrome. Firefox и Safari формулируют свои иначе; причины ниже те же самые, из какого браузера ни пришло бы сообщение.
Что на самом деле говорит каждое сообщение
Если прогнать это в консоли браузера, получается вот что:
JSON.parse("") // Unexpected end of JSON input
JSON.parse(" ") // Unexpected end of JSON input
JSON.parse("undefined") // "undefined" is not valid JSON
JSON.parse("<html>") // Unexpected token '<', "<html>" is not valid JSON
JSON.parse('{"a":1,') // Expected double-quoted property name in JSON
// at position 7 (line 1 column 8)
В этом списке есть диагностическое правило, которого никто не произносит вслух, и оно самое полезное на этой странице:
- Сообщение с номером позиции означает, что JSON у вас действительно есть, и он кончается рано или становится неправильным на середине. Что-то обрезало настоящую полезную нагрузку.
- Сообщение, которое цитирует фрагмент и заканчивается на “is not valid JSON”, означает, что это никогда не было JSON. Вам отдали нечто совсем другое.
- “Unexpected end of JSON input” означает, что парсер дошёл до конца, не получив целого значения, что на практике значит пустое тело. Пробелы считаются пустотой.
Это три разные ошибки с тремя разными исправлениями, и текст ошибки это единственное, что их разделяет.
Пустое тело и четыре способа его получить
“Unexpected end of JSON input” это то, что выдаёт JSON.parse(""), так что вопрос в том, почему
тело оказалось пустым.
Ответ 204 No Content это честный вариант: сервер говорит вам, что там ничего нет, и вызвать
на нём .json() и есть ошибка. 200 с пустым телом это та же ситуация без предупреждения.
Уже прочитанное тело даёт то же самое: тела Response это потоки и читаются один раз, поэтому
await res.text(), за которым идёт await res.json(), падает на втором вызове именно с этим
сообщением. А прерванный запрос может оставить вам частичное или пустое тело в зависимости от
того, где его оборвали.
Веб-страница, и это частый случай
Второе сообщение это то, с которым вы столкнётесь по-настоящему, и оно означает, что ваш запрос вернул HTML.
Настолько частым его делает одна особенность fetch, на которой каждый спотыкается однажды:
fetch не отклоняется на 4xx и 5xx. Для промиса 404 это совершенно успешный fetch. Поэтому
если адрес неверный, или сессия истекла и прокси ответил страницей входа, или межсетевой экран
уровня приложений выдал проверку, или сервер бросил 500 и ваш фреймворк отрисовал собственную
страницу ошибки, res.json() получает документ, который начинается с <!DOCTYPE html>, и
сообщает про <.
Страница входа худшая из них, потому что обычно приходит как 200. С кодом состояния всё в порядке. Тело просто форма входа.
Улучшение, которое останавливается за один символ до пользы
Раньше эти сообщения были хуже. Issue 6551 в V8, “JSON.parse errors can be user-friendly for short
strings”, открыли в 2017 году и реализовали в мае 2022: до него
Unexpected token N in JSON at position 0, после него "NaN" is not valid JSON. Новая форма
цитирует то, что ей дали, и это настоящее улучшение и причина, по которой оборот
“in JSON at position 0” почти исчез из браузеров, оставаясь в каждом результате поиска о нём.
У этой цитаты есть ограничение по длине, и измерить его стоит минуты, потому что нигде оно не задокументировано. Разберите строку из двадцати символов или короче, и сообщение процитирует её целиком. С двадцати одного символа оно цитирует первые десять символов и добавляет многоточие.
Теперь посчитайте символы в <!DOCTYPE . Их ровно десять.
Значит, для самой частой причины этой ошибки полезный новый фрагмент каждый раз выглядит как
"<!DOCTYPE ".... Страница 404, страница 500, перенаправление на вход и проверка межсетевого
экрана дают посимвольно одно и то же сообщение. Запрос настоящей 404 с нашего собственного сайта,
HTML-документ размером 3407 байт, даёт именно это и ничего больше.
Сообщение выглядит так, будто цитирует сервер для вас. Для случая, в котором вы скорее всего и находитесь, оно цитирует ту единственную часть ответа, которая одинакова у каждой HTML-страницы.
Вот почему исправление никогда не в парсере
Проверяйте ответ до того, как его разбирать, и говорите, что нашли, когда он неправильный:
const res = await fetch(url);
if (!res.ok) {
throw new Error(`${res.status} ${res.statusText} from ${res.url}`);
}
const type = res.headers.get("content-type") || "";
if (!type.includes("application/json")) {
throw new Error(`expected JSON, got ${type || "no content-type"} from ${res.url}`);
}
return res.json();
Обе проверки заслуживают своего места. Первая существует потому, что fetch не бросает на плохом
статусе. Вторая существует потому, что 200 всё равно может быть HTML, а это ровно случай со
страницей входа, и потому, что она превращает нечитаемую ошибку парсера во фразу, называющую
статус, тип и адрес.
А версия этого, которая приходит в тикете, ещё хуже
Всё перечисленное выше предполагает, что ответ вы видите. Человек, который сообщает об ошибке, его не видит.
Приходит “там что-то про JSON” или в лучшем случае фотография строки консоли со словами
is not valid JSON. Эта строка не несёт ни кода состояния, ни content-type, ни запрошенного
адреса, ни тела. Все пять причин выше дают один и тот же текст в консоли, и четыре из пяти не
видны без ответа.
Что делает эту ошибку одной из тех, где консоли недостаточно, а нужная вещь это вкладка сети: запрос, его статус, его content-type и первые несколько сотен байт того, что вернулось. Открыть вкладку сети это минута работы для того, кто уже это делал, и другой день для того, кто не делал.
Стоит отделить от соседа, потому что люди ищут одно, имея другое:
TypeError: Failed to fetch это запрос, который так и не доходит до конца. Эта ошибка
это запрос, который доходит прекрасно и возвращает не то.
Session Replay
Бесплатное расширение для Chrome. Один клик на странице, которая ведёт себя не так, как надо, захватывает скриншот, консоль и сетевой журнал и выдаёт вам ссылку, которую можно вставить в тикет.
Что из этого забрать
Сообщение называет JSON потому, что JSON.parse это место, где ваша программа остановилась, а не
место, где она пошла не туда. Читайте его как утверждение об ответе: пустой, или не JSON, или
обрезанный. А потом идите смотреть ответ, потому что парсер уже сказал вам всё, что знает.