AJAX-форма ожидает JSON, но получает HTML: разберите ответ шаг за шагом
Ответ 200 не доказывает, что это JSON. Пройдите панель «Сеть» шаг за шагом и разделите сбои транспорта, представления и контракта.
Форма отправляется, появляется индикатор, и консоль сообщает о неожиданном символе < в JSON на позиции 0. Сама страница загружается нормально, поэтому первый рефлекс — обвинить парсер или туннель. Обычно ни тот ни другой не участвует: отправка — это отдельный запрос, а не загрузка страницы, и она вернулась с чем-то, что не является JSON. Работайте в том порядке, в котором браузер действительно это раскрывает, потому что четыре возможных ответа требуют четырёх разных исправлений.
Шаг 1: поймайте отправку, которая действительно произошла
Откройте панель «Сеть» до отправки, настройте журнал так, чтобы записи сохранялись при переходах, и сделайте одну безобидную тестовую отправку в аккаунте, который вам разрешено использовать. Выберите запрос, который инициировала сама форма, а не запрос документа страницы.
Осторожнее с фильтрами. Фильтр XHR или Fetch удобен, но если фреймворк всё ещё отправляет через скрытый iframe или динамически созданную форму, фильтр скроет единственный важный запрос. Сначала отправьте один раз без фильтра и посмотрите, что появилось.
Для выбранного запроса запишите шесть вещей до любого толкования: метод, полный URL вместе со строкой запроса, статус, цепочку перенаправлений, Content-Type ответа и первые строки тела. Справочник по панели «Сеть» в Chrome DevTools описывает представления Headers, Response и Initiator, где это находится, а аналогичные панели в других браузерах показывают те же поля.
Шаг 2: убедитесь, что это тот самый запрос
Удивительно много ошибок JSON — на самом деле ошибки маршрутизации. Если обработчик отправки выбрасывает исключение до вызова preventDefault, браузер выполняет собственный POST формы по адресу из атрибута action, и сервер отвечает полной HTML-страницей. Обработчик не выполняется, значит JSON вообще не запрашивался.
Сравните URL на панели с URL, который вызывает ваш JavaScript. Ищите перенаправление, заканчивающееся в другом месте, отсутствующий или лишний завершающий слэш, несовпадение метода, когда обработчик шлёт GET, а конечная точка принимает POST, или форму, отправляющую на относительный путь со страницы, которая переехала. Проследите цепочку перенаправлений до конца и запишите конечный URL, а не первый.
Затем проверьте инициатора. Если запрос пришёл из навигации документа, а не из вашего скрипта, проблема уже найдена, и никакие изменения в парсере ответа не помогут.
Шаг 3: разложите результат по четырём категориям
Каждый результат относится ровно к одной из них, и категория определяет, где смотреть дальше.
| What you see | Category | Where to look |
|---|---|---|
| Request never finishes, TLS or connection error | No usable response | Endpoint reachability, certificate, front-end timeout |
| Request completes, script gets an opaque failure | Access blocked | Cross-origin policy, credentials mode, missing response header |
| 200 with an HTML body or a login form | Wrong representation | Redirect, expired session, route fallback, error handler |
| Body begins with a notice then valid JSON | Wrong representation | Server-side warning output before serialisation |
| Valid JSON, framework rejects it | Contract mismatch | Envelope shape, field names, value types |
Средние строки чаще всего диагностируют неверно, потому что статус 200. Страница входа, достигнутая после перенаправления, — совершенно нормальный ответ HTTP и очень плохой документ JSON. Тип содержимого application/json это не исправляет: это лишь ярлык, а неверно помеченное тело всё равно не парсится. «Доступ заблокирован» — строка, которая выглядит как завершённый запрос: панель показывает статус, но политика кросс-доменных запросов удерживает тело, поэтому для вызывающего скрипта оно так же бесполезно, как таймаут.
Четвёртая строка столь же обычна на старых стеках PHP и фреймворков. Предупреждение об устаревании, напечатанное до закодированной нагрузки, даёт тело, которое не примет ни один парсер JSON, и часто это предупреждение и есть всё объяснение.
Шаг 4: читайте ошибку парсера буквально
Текст ошибки называет первый символ, сломавший разбор, и это обычно определяет категорию без всяких догадок.
| Error text (Chrome/V8) | What it means |
|---|---|
| Unexpected token < in JSON at position 0 | The body starts with markup: an HTML page or an error document |
| Unexpected end of JSON input | Empty body, or the response was truncated |
| Unexpected token o in JSON at position 1 | You parsed something already parsed, typically an object rather than text |
| Unexpected token u | The body starts with the bare word undefined, usually an empty response rendered as a string |
Пустое тело со статусом 200 — отдельный случай: конечная точка отработала, ничего не произвела, а что-то ниже по потоку приняло это за успех. Это сбой контракта приложения, а не транспорта, и его место в следующем шаге, а не в прокси. Firefox и Safari формулируют те же четыре сбоя иначе, поэтому сопоставляйте по символу или по пустому телу, а не по точной строке.
Шаг 5: исправьте слой, на который указывают доказательства
При проблеме маршрутизации исправьте запрос: поправьте action, не дайте обработчику провалиться, согласуйте конечную точку и метод и проверьте снова. При проблеме представления исправьте то, что породило тело, и выясните, почему документ ошибки возвращается там, где ожидались данные. При проблеме контракта прочитайте реальную ожидаемую структуру фреймворка для вашей версии и приведите ответ к ней, вместо того чтобы ослаблять клиент.
Ответ с заблокированным доступом требует отдельного прохода, потому что сам запрос может быть корректным, а тело может так и не дойти до вашего кода. Сравните Access-Control-Allow-Origin ответа с точным Origin, который отправил браузер, и помните, что подстановочный знак не удовлетворяет запрос с учётными данными. Свяжите credentials: 'include' на клиенте с Access-Control-Allow-Credentials: true на сервере и проверьте, не использовал ли вызов режим no-cors, где непрозрачное тело — документированный результат, а не дефект.
Замена парсера на что-то более снисходительное — единственное исправление, которого стоит избегать. Выполнение ответа как кода или срезание текста до тех пор, пока он не разберётся, скрывает несоответствие контракта и добавляет поверхность для инъекций в обмен на тишину. Точно так же статус нужно проверять отдельно от тела: 200 с объектом ошибки уровня приложения нормален во многих API, и его нужно обрабатывать, читая нагрузку.
Когда нужна помощь другого человека, поделитесь минимальной записью — метод, URL, статус, конечный URL после перенаправлений, тип содержимого ответа, форма тела и ожидаемый контракт — вместо необработанного экспорта. Сетевые архивы содержат cookie, токены, строки запроса и персональные данные, которые были в форме, а автоматическая очистка — не проверка.
- Поймайте запрос без фильтра, прежде чем менять любой код.
- Убедитесь, что инициатором был ваш скрипт, а конечный URL — ваша конечная точка.
- Классифицируйте ответ, прежде чем трогать парсер.
- Исправьте маршрутизацию, доступ, представление или контракт и никогда — ослабляя парсер.