CLODEx.
ПРАКТИКА / CLODEx

API вернул HTML вместо JSON: как найти неправильный маршрут или прокси

Ошибка Python JSONDecoder означает, что конкретное тело не удалось разобрать как JSON; сама по себе она не доказывает сбой модели, провайдера или прокси. Сначала зафиксируйте безопасные метаданные: метод, публичный шаблон маршрута, факт редиректа и разрешённый origin без query/path-идентификаторов, статус, нормализованную категорию Content-Type и наличие тела. Затем сопоставьте их с локальными fixtures для HTML, JSON, некорректного JSON и пустого ответа. Проверенные наблюдения следует отделять от гипотез о маршрутизации, авторизации, gateway или инфраструктуре.

Редакционная проверка · / Диагностика API

Проверяем транспорт до JSON

Начните с ответа на уровне HTTP, не вызывая JSON-декодер вслепую. Безопасно сохранять метод, публичный шаблон маршрута без query и идентификаторов в сегментах, статус, категорию Content-Type из разрешённого списка, факт редиректа и конечный origin, если он заранее разрешён политикой приложения. Не записывайте Authorization, cookies, API-ключи, полные URL с секретами и полные тела ошибок. Content-Type является полезным сигналом, но не доказывает, что тело действительно соответствует заявленному формату. Аналогично, статус 200 не гарантирует успешную бизнес-операцию: это нужно проверять по контракту конкретного API.

Что означает ошибка JSON-декодирования

JSONDecodeError сообщает о невозможности разобрать переданные байты или текст как JSON. Причиной может быть HTML-страница, текстовое сообщение gateway, обрезанное тело, пустой ответ или действительно некорректный JSON. Ошибка декодирования не устанавливает, какой компонент сформировал ответ, и не доказывает отказ модели или смену провайдера. Для диагностики сохраните только тип ошибки и безопасный контекст: статус, Content-Type, длину тела и, при необходимости, ограниченный отпечаток или локальный идентификатор fixture вместо содержимого.

Матрица синтетического воспроизведения

Подготовьте локальные fixtures без секретов и пользовательских данных: HTML со статусом 200, HTML с HTTP-ошибкой, корректный JSON, некорректный JSON и пустое тело. Для каждой записи отдельно проверяйте: был ли HTTP-статус допустимым, соответствует ли Content-Type ожидаемому, есть ли тело и успешно ли оно разбирается. Такая матрица помогает не смешивать транспортную ошибку с ошибкой формата. Пример ниже является иллюстративным и не заявляется как протестированный. Код рассчитан на Python 3.9+ и на учебный контракт, где успешный ответ обязан содержать application/json. HEAD, 204 и дополнительные JSON media types требуют собственной политики; пустое тело не всегда ошибка HTTP. Лимит 65 536 символов локальный, не лимит провайдера. NaN/Infinity отклоняются, глубокая вложенность выделяется отдельно. Разбор JSON ещё не проверяет бизнес-схему, обязательные поля или дубликаты ключей.

python
from dataclasses import dataclass
import json
from typing import Any, Optional

@dataclass
class SyntheticResponse:
    status: int
    content_type: Optional[str]
    body: str


def classify_response(response: SyntheticResponse) -> dict[str, Any]:
    if not 200 <= response.status < 300:
        return {"kind": "http_error", "status": response.status}

    if len(response.body) > 65536:
        return {"kind": "body_too_large", "status": response.status}

    if not response.body:
        return {"kind": "empty_body", "status": response.status}

    # This example contract explicitly permits only application/json.
    content_type = (response.content_type or "").split(";", 1)[0].strip().lower()
    if content_type != "application/json":
        return {
            "kind": "unexpected_content_type",
            "status": response.status,
        }

    try:
        def reject_non_json_constant(value):
            raise ValueError("non-JSON numeric constant")
        value = json.loads(response.body, parse_constant=reject_non_json_constant)
    except (json.JSONDecodeError, ValueError):
        return {"kind": "invalid_json", "status": response.status}

    except RecursionError:
        return {"kind": "json_resource_limit", "status": response.status}

    return {"kind": "json", "status": response.status, "value_type": type(value).__name__}


fixtures = {
    "html_200": SyntheticResponse(200, "text/html", "<html>synthetic</html>"),
    "html_error": SyntheticResponse(502, "text/html", "<html>synthetic gateway error</html>"),
    "valid_json": SyntheticResponse(200, "application/json", '{"ok": true}'),
    "invalid_json": SyntheticResponse(200, "application/json", "{not-json}"),
    "empty": SyntheticResponse(200, "application/json", ""),
}

expected = {"html_200": "unexpected_content_type", "html_error": "http_error",
            "valid_json": "json", "invalid_json": "invalid_json", "empty": "empty_body"}
for name, response in fixtures.items():
    assert classify_response(response)["kind"] == expected[name]

Разбираем маршрут, редирект и gateway

Если получен HTML, проверьте, не обращается ли клиент к web-маршруту вместо API-маршрута, не ведёт ли путь на страницу авторизации и не был ли запрос перенаправлен на другой origin. Отдельно сравните ожидаемый path с фактическим конечным URL и правилами reverse proxy. Редирект, страница входа, ответ балансировщика и ответ приложения — разные гипотезы, которые нельзя объявлять подтверждённым инцидентом без наблюдаемого статуса, заголовков и контролируемого воспроизведения. Не переходите по адресу, найденному в теле HTML, и не отключайте проверку TLS ради эксперимента.

Безопасная классификация для приложения

Пользователю достаточно сообщить категорию: HTTP-ошибка, неожиданный Content-Type, пустое тело или некорректный JSON. Внутренний журнал может содержать корреляционный идентификатор, статус, категорию Content-Type из allowlist и размер тела, но не секретные заголовки и не полный ответ. Не следует автоматически повторять запрос при любом JSONDecodeError: повтор допустим только при явно определённых, идемпотентных и временных условиях. Логика обработки должна учитывать контракт API, допустимые статусы и политику редиректов.

Границы вывода

По одному HTML-ответу нельзя надёжно определить, какой компонент его создал, был ли причиной прокси, неверный маршрут, авторизация или инфраструктурная ошибка. Для вывода нужны сопоставимые синтетические или разрешённые диагностические запросы, наблюдаемые HTTP-метаданные и проверка конфигурации маршрутизации. Не публикуйте токены, cookies, пользовательские запросы, полные тела ошибок или секретные заголовки даже в закрытых примерах без отдельной политики доступа и редактирования.

Шаблон промпта

Составь на русском технический материал для диагностики случая, когда HTTP-клиент ожидал JSON, но получил HTML или другой ответ. Объясни различие между JSONDecodeError и причиной неправильного ответа; используй RFC 9110 только для описания общей семантики HTTP status и representations, а документацию Python — для поведения JSON-декодера. Приведи локальную матрицу синтетических fixtures и безопасный пример классификации ответа, явно отметив, что пример не тестировался. Не выдумывай цены, бенчмарки, результаты испытаний, акции, совместимость или подтверждённый инцидент. Не публикуй токены, cookies, пользовательские запросы, полные HTML-тела, секретные заголовки и реальные ключи; для ключей используй переменные окружения. Отделяй наблюдения от гипотез о маршруте, редиректе, gateway и авторизации. Не советуй отключать TLS-проверку или отправлять учётные данные по адресу из HTML.

Источники и проверка