application/problem+json: как не принять HTML-ошибку за JSON
Практический guide по RFC 9457: сначала проверяем HTTP-статус и Content-Type, затем безопасно разбираем Problem Details. На Python 3.12 воспроизводим корректный 422, HTML от шлюза, сломанный JSON, неверные типы и расхождение статусов.

В этой статье
Клиент API не должен начинать с безусловного разбора JSON. Сначала он проверяет фактический HTTP-статус и базовый media type ответа. Только application/problem+json передаётся в обработчик Problem Details; HTML от прокси, пустое тело и сломанный JSON останавливаются как отдельные ошибки. Поле status внутри тела полезно для журнала, но не заменяет статус строки HTTP.
На маленьком примере импорт товара получает пять ответов: корректный 422, HTML от шлюза с 502, повреждённый JSON, поля неверных типов и расхождение между HTTP 503 и status 429 в теле. Один обработчик должен различить эти случаи, не показывая оператору трассировку и не принимая чужой формат за договорённый.
Что даёт Problem Details
RFC 9457 описывает объект Problem Details и media type application/problem+json. Формат нужен для машиночитаемых деталей ошибки и не отменяет семантику HTTP-статуса. Спецификация также предупреждает, что problem details не стоит превращать в отладочную свалку: внутренние сведения могут раскрыть устройство системы. Подробное описание полей приведено в RFC 9457.
- type — основной идентификатор типа проблемы; при отсутствии подразумевается about:blank
- title — короткое человекочитаемое название типа проблемы
- status — копия HTTP-статуса для удобства, а не новый источник истины
- detail — пояснение именно этого случая
- instance — идентификатор конкретного возникновения проблемы
Расширения разрешены. Клиент, который их не знает, должен игнорировать их, а не отвергать весь ответ. Поэтому учебный trace_id не попадает в нормализованный результат: он не нужен общему обработчику.
Первый фильтр — Content-Type
Параметр charset допустим, поэтому сравниваем только базовый media type без учёта регистра и пробелов. Ответ text/html с кодом 502 не отправляем в json.loads: иначе настоящий сбой шлюза превратится в misleading JSONDecodeError и потеряет исходный статус.
HTTP и Content-Type | Действие клиента | Что сохранить |
|---|---|---|
422; application/problem+json | Разобрать объект | HTTP 422, type, title, detail, instance |
502; text/html | Не разбирать как JSON | HTTP 502 и безопасную причину отказа формата |
500; application/problem+json, повреждённое тело | Остановить разбор | HTTP 500 и признак invalid problem JSON |
503; в теле status 429 | Отметить расхождение | HTTP 503, body status 429, mismatch |
Минимальный проверяемый клиент
Проверка выполнена 18 октября 2026 года на CPython 3.12.14 только со стандартной библиотекой. Это чистая модель ответа: внешняя сеть, рабочий магазин и API 1С-Битрикс не используются.
from dataclasses import dataclass
import json
@dataclass(frozen=True)
class Response:
status: int
content_type: str
body: bytes
def parse_problem(response: Response) -> dict:
media_type = response.content_type.split(";", 1)[0].strip().lower()
if media_type != "application/problem+json":
raise ValueError(f"unexpected media type: {media_type or 'missing'}")
try:
raw = json.loads(response.body)
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise ValueError("invalid problem JSON") from error
if not isinstance(raw, dict):
raise ValueError("problem details must be a JSON object")
def text(name: str, default: str | None = None) -> str | None:
value = raw.get(name, default)
return value if isinstance(value, str) else default
body_status = raw.get("status")
if not isinstance(body_status, int) or isinstance(body_status, bool):
body_status = None
return {
"http_status": response.status,
"type": text("type", "about:blank"),
"title": text("title"),
"detail": text("detail"),
"instance": text("instance"),
"body_status": body_status,
"status_mismatch": body_status is not None and body_status != response.status,
}
Функция принимает уже полученный ответ. Она не пытается автоматически открыть type URI, не выводит сырое тело пользователю и отбрасывает стандартные поля неверного типа. Булево значение для status тоже не принимается: в Python bool является подклассом int, поэтому это условие проверено отдельно.
Пять ответов и ожидаемый результат
cases = {
"valid": Response(
422,
"application/problem+json; charset=utf-8",
json.dumps({
"type": "https://api.example.test/problems/invalid-stock",
"title": "Invalid stock",
"status": 422,
"detail": "stock must be zero or greater",
"instance": "/imports/7/items/42",
"trace_id": "demo-123",
}).encode(),
),
"html_gateway": Response(502, "text/html", b"<h1>Bad Gateway</h1>"),
"broken_json": Response(500, "application/problem+json", b'{"title":'),
"wrong_types": Response(
400,
"application/problem+json",
b'{"type":7,"title":"Bad request","status":"400"}',
),
"mismatch": Response(
503,
"application/problem+json",
b'{"title":"Busy","status":429}',
),
}
for name, response in cases.items():
try:
problem = parse_problem(response)
except ValueError as error:
print(f"{name}: reject: {error}")
else:
print(
f"{name}: accept: http={problem['http_status']} "
f"type={problem['type']} body_status={problem['body_status']} "
f"mismatch={problem['status_mismatch']}"
)
valid: accept: http=422 type=https://api.example.test/problems/invalid-stock body_status=422 mismatch=False
html_gateway: reject: unexpected media type: text/html
broken_json: reject: invalid problem JSON
wrong_types: accept: http=400 type=about:blank body_status=None mismatch=False
mismatch: accept: http=503 type=about:blank body_status=429 mismatch=True
Скрипт завершился с кодом 0. Корректный 422 принят; HTML и повреждённый JSON отклонены до бизнес-обработки; неверные типы не разрушили весь объект; расхождение 503/429 стало явным флагом. Это наблюдения учебного запуска, а не измерение конкретного внешнего сервиса.

Почему status в теле не должен управлять повтором
RFC называет status в объекте advisory. Промежуточный узел может изменить HTTP-статус, а сохранённое тело — остаться прежним. Если сеть сообщает 503, а JSON утверждает 429, клиент не должен молча выбрать удобное число. Он сохраняет оба значения, отмечает расхождение и применяет политику по фактическому HTTP-ответу и контракту интеграции.
Для паузы после 429 нужен отдельный разбор заголовка Retry-After. Он проверен в материале 429 Retry-After: секунды или дата. Problem Details может пояснить ошибку, но не заменяет правила повторов.
Как встроить в интеграцию магазина
Для 1С-Битрикс: Управление сайтом это не обещание встроенного формата конкретного метода. Сначала в контракте явно указывают внешний API, версию, операцию, права, допустимые HTTP-статусы и Content-Type. Если поставщик возвращает собственную схему ошибок, под неё нужен отдельный адаптер; переименовать поля в Problem Details на стороне клиента недостаточно.
- Транспортный слой сохраняет HTTP-статус, Content-Type и ограниченный диагностический идентификатор без секретов.
- Диспетчер выбирает обработчик по базовому media type.
- Обработчик Problem Details валидирует JSON-объект и типы стандартных полей.
- Бизнес-слой принимает решение по HTTP-статусу, type и документированным расширениям.
- Неизвестный type или расширение не запускает догадки: пакет останавливается либо уходит в ручную проверку.
- Сырое тело и instance не выводятся покупателю без проверки на персональные и внутренние данные.
Протокол приёмки
- Подать корректный application/problem+json с параметром charset; ожидать сохранённые HTTP-статус, type и detail.
- Подать text/html с 502; убедиться, что JSON-парсер не вызван и исходный HTTP-статус не потерян.
- Подать повреждённый JSON с правильным media type; ожидать отдельную ошибку invalid problem JSON.
- Подать массив вместо объекта и стандартные поля неверных типов; массив отклонить, неверные поля игнорировать по контракту.
- Подать несовпадающие HTTP status и поле status; сохранить оба значения и поднять mismatch.
- Добавить неизвестное расширение; убедиться, что базовые поля продолжают обрабатываться.
- Проверить журнал: в нём нет токенов, cookie, полного HTML, персональных данных и трассировки стека.
Границы примера
Учебный код не выполняет HTTP-запрос, не проверяет TLS, размер тела, кодировку Content-Encoding и лимиты памяти. В рабочем клиенте они проверяются до разбора. Также нужен предел для detail и журналирования, иначе корректный media type всё ещё может принести слишком большой или чувствительный текст.
Problem Details помогает унифицировать форму ошибки, но не исправляет неверный статус, не задаёт стратегию повтора и не заменяет документацию problem type. Критерий готовности простой: каждый неожиданный формат или конфликт статусов остаётся видимым, а не превращается в случайное исключение JSON-парсера.
An API client must not begin by blindly parsing JSON. First, it checks the actual HTTP status and the basic media type of the response. Only application/problem+json is passed to the Problem Details handler; HTML from a proxy, an empty body, and broken JSON are treated as distinct errors. The status field inside the body is useful for logging but does not replace the HTTP status line.
In a small example, an import operation receives five responses: a correct 422, HTML from a gateway with 502, corrupted JSON, fields with incorrect types, and a mismatch between HTTP 503 and status 429 in the body. A single handler must distinguish these cases without exposing stack traces to the operator or accepting an external format as an agreement.
What Problem Details Provides
RFC 9457 describes the Problem Details object and the application/problem+json media type. The format is intended for machine-readable error details and does not override HTTP status semantics. The specification also warns that problem details should not become a debugging dump: internal information may reveal system internals. A detailed description of the fields is provided in RFC 9457.
- type — the primary identifier for the problem type; defaults to about:blank if absent
- title — a short, human-readable name for the problem type
- status — a copy of the HTTP status for convenience, not a new source of truth
- detail — an explanation specific to this case
- instance — an identifier for the specific occurrence of the problem
Extensions are allowed. A client unaware of them must ignore them, not reject the entire response. Therefore, the sample trace_id does not appear in the normalized result: the general handler does not need it.
The first filter is Content-Type
The charset parameter is allowed, so we compare only the base media type, ignoring case and whitespace. We do not pass a text/html response with status 502 to json.loads: otherwise, a genuine gateway failure would turn into a misleading JSONDecodeError and lose the original status code.
HTTP and Content-Type | Client action | What to save |
|---|---|---|
422; application/problem+json | Parse the object | HTTP 422, type, title, detail, instance |
502; text/html | Do not parse as JSON | HTTP 502 and a safe reason for format rejection |
500; application/problem+json, corrupted body | Stop parsing | HTTP 500 and the invalid problem JSON flag |
503; status 429 in the body | Flag discrepancy | HTTP 503, body status 429, mismatch |
Minimal testable client
The check was performed on October 18, 2026, on CPython 3.12.14 using only the standard library. This is a pure response model: the external network, a live store, and the 1C-Bitrix API are not used.
from dataclasses import dataclass
import json
@dataclass(frozen=True)
class Response:
status: int
content_type: str
body: bytes
def parse_problem(response: Response) -> dict:
media_type = response.content_type.split(";", 1)[0].strip().lower()
if media_type != "application/problem+json":
raise ValueError(f"unexpected media type: {media_type or 'missing'}")
try:
raw = json.loads(response.body)
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise ValueError("invalid problem JSON") from error
if not isinstance(raw, dict):
raise ValueError("problem details must be a JSON object")
def text(name: str, default: str | None = None) -> str | None:
value = raw.get(name, default)
return value if isinstance(value, str) else default
body_status = raw.get("status")
if not isinstance(body_status, int) or isinstance(body_status, bool):
body_status = None
return {
"http_status": response.status,
"type": text("type", "about:blank"),
"title": text("title"),
"detail": text("detail"),
"instance": text("instance"),
"body_status": body_status,
"status_mismatch": body_status is not None and body_status != response.status,
}
The function accepts an already received response. It does not attempt to automatically open the type URI, does not output raw body content to the user, and discards standard fields of the wrong type. A boolean value for status is also not accepted: in Python, bool is a subclass of int, so this condition is checked separately.
Five responses and the expected result
cases = {
"valid": Response(
422,
"application/problem+json; charset=utf-8",
json.dumps({
"type": "https://api.example.test/problems/invalid-stock",
"title": "Invalid stock",
"status": 422,
"detail": "stock must be zero or greater",
"instance": "/imports/7/items/42",
"trace_id": "demo-123",
}).encode(),
),
"html_gateway": Response(502, "text/html", b"<h1>Bad Gateway</h1>"),
"broken_json": Response(500, "application/problem+json", b'{"title":'),
"wrong_types": Response(
400,
"application/problem+json",
b'{"type":7,"title":"Bad request","status":"400"}',
),
"mismatch": Response(
503,
"application/problem+json",
b'{"title":"Busy","status":429}',
),
}
for name, response in cases.items():
try:
problem = parse_problem(response)
except ValueError as error:
print(f"{name}: reject: {error}")
else:
print(
f"{name}: accept: http={problem['http_status']} "
f"type={problem['type']} body_status={problem['body_status']} "
f"mismatch={problem['status_mismatch']}"
)
valid: accept: http=422 type=https://api.example.test/problems/invalid-stock body_status=422 mismatch=False
html_gateway: reject: unexpected media type: text/html
broken_json: reject: invalid problem JSON
wrong_types: accept: http=400 type=about:blank body_status=None mismatch=False
mismatch: accept: http=503 type=about:blank body_status=429 mismatch=True
The script exited with code 0. The correct 422 was accepted; HTML and corrupted JSON were rejected before business processing; incorrect types did not corrupt the entire object; the 503/429 mismatch became an explicit flag. These are observations from a sample run, not a measurement of a specific external service.

Why the status in the body must not control retries
RFC refers to status in the object as advisory. An intermediate node may change the HTTP status while the saved body remains unchanged. If the network reports 503 but the JSON claims 429, the client must not silently choose the convenient number. It preserves both values, flags the discrepancy, and applies policy based on the actual HTTP response and the integration contract.
A pause after 429 requires separate parsing of the Retry-After header. It is verified in the article 429 Retry-After: seconds or date. Problem Details may explain the error but does not replace retry rules.
How to embed this into a store integration
For 1C-Bitrix: Site Management is not a promise of a built-in format for a specific method. First, the contract must explicitly specify the external API, version, operation, permissions, allowed HTTP statuses, and Content-Type. If the provider returns its own error schema, a separate adapter is required; renaming fields to Problem Details on the client side is insufficient.
- The transport layer preserves the HTTP status, Content-Type, and a limited diagnostic identifier without secrets.
- The dispatcher selects the handler based on the base media type.
- The Problem Details handler validates the JSON object and the types of standard fields.
- The business layer makes a decision based on the HTTP status, type, and documented extensions.
- An unknown type or extension does not trigger guesses: the package is either stopped or sent for manual review.
- The raw body and instance are not displayed to the buyer without checking for personal and internal data.
Acceptance Testing
- Submit a valid application/problem+json with the charset parameter; expect the preserved HTTP status, type, and detail.
- Submit text/html with 502; ensure the JSON parser is not invoked and the original HTTP status is not lost.
- Submit corrupted JSON with the correct media type; expect a separate error for invalid problem JSON.
- Submit an array instead of an object and standard fields of incorrect types; reject the array and ignore incorrect fields per the contract.
- Submit a mismatched HTTP status and status field; preserve both values and raise a mismatch.
- Add an unknown extension; ensure basic fields continue to be processed.
- Check the log: it must not contain tokens, cookies, full HTML, personal data, or stack traces.
Example Boundaries
The test code does not perform an HTTP request, does not check TLS, body size, Content-Encoding, or memory limits. In a production client, these are checked before parsing. A limit is also needed for detail and logging; otherwise, even a correct media type could still deliver text that is too large or sensitive.
Problem Details helps unify the error format, but it does not fix an incorrect status, does not define a retry strategy, and does not replace documentation for the problem type. The readiness criterion is simple: every unexpected format or status conflict remains visible instead of turning into a random JSON parser exception.





Обсуждение 0
Делись опытом и задавай вопросы. Комментарии без ссылок появляются после проверки редактором.
Пока никто не написал. Начни обсуждение.