Что-то вызывает 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, или обрезанный. А потом идите смотреть ответ, потому что парсер уже сказал вам всё, что знает.