В обработчике ответа часто встречается одна строка: if (!$data) { throw new Exception("bad response"); }. После неё невозможно понять, что случилось: партнёр вернул пустой список, честное null, число 0 или HTML вместо JSON. Ниже я оставляю пример в рамках PHP 7.1: в этой версии ещё нет JSON_THROW_ON_ERROR, поэтому после json_decode() нужно явно проверить состояние декодера.
Главный вопрос здесь узкий: как отделить ошибку разбора JSON от корректного JSON, который не соответствует нашему договору? Ответ состоит из двух проверок подряд. Сначала сразу читаем json_last_error(). Только если там JSON_ERROR_NONE, проверяем тип и обязательные поля ответа. Цена ошибки — показать пользователю пустой результат там, где партнёр вернул повреждённый или чужой формат.
Почему null не доказывает ошибку
По RFC 8259 JSON-текстом может быть не только объект или массив: допустимы также строка, число, false, true и null. PHP отражает это напрямую: json_decode("null") возвращает null, но null возвращается и когда строку нельзя декодировать. Одна проверка на значение не различает эти случаи.
То же происходит с пустыми коллекциями. После json_decode("[]", true) получится пустой массив, который в PHP является ложным в условии. Это может быть правильный ответ поиска: товаров нет. Но тот же if (!$data) назовёт его «битым JSON». Сначала нужно проверить синтаксис, затем форму данных, и только потом решать, допустим ли пустой результат для данной операции.
Короткая таблица, которую стоит держать рядом с кодом
| Сырой ответ | Результат json_decode(..., true) | json_last_error | Что это значит для клиента |
|---|---|---|---|
{"order_id":"A-17"} | ассоциативный массив | JSON_ERROR_NONE | Проверить поле order_id и принять ответ |
[] | пустой массив | JSON_ERROR_NONE | Корректный JSON; допустимость зависит от операции |
null | null | JSON_ERROR_NONE | Корректный JSON, но не тот тип, который ждёт данный endpoint |
false или 0 | false или 0 | JSON_ERROR_NONE | Корректный JSON; проверка на «ложь» здесь ошибочна |
<html>503</html> | обычно null | JSON_ERROR_SYNTAX | Неверный формат ответа; сохранить безопасный диагностический контекст |
Пример для PHP 7.1
Функция ниже принимает уже полученное тело только после проверки HTTP-статуса. Она не пытается угадать, где возникли данные: транспорт и HTTP должны быть разобраны раньше. Здесь контракт намеренно маленький: мы ждём объект с непустым строковым order_id. В другом API это может быть список, поле accepted или код задачи — меняется проверка контракта, но не порядок диагностики.
<?php
function logPayloadProblem(array $record)
{
error_log(json_encode($record, JSON_UNESCAPED_UNICODE));
}
function rejectPayloadContract($reason, $requestId, $body)
{
logPayloadProblem(array(
'kind' => 'contract_error',
'reason' => $reason,
'request_id' => $requestId,
'body_bytes' => strlen($body),
'body_sha256' => hash('sha256', $body),
));
throw new UnexpectedValueException($reason);
}
function decodeCreatedOrder($body, $requestId)
{
if ($body === '') {
rejectPayloadContract('Partner returned an empty body', $requestId, $body);
}
$data = json_decode($body, true);
$jsonError = json_last_error();
if ($jsonError !== JSON_ERROR_NONE) {
logPayloadProblem(array(
'kind' => 'json_decode_error',
'request_id' => $requestId,
'json_error' => $jsonError,
'body_bytes' => strlen($body),
'body_sha256' => hash('sha256', $body),
));
throw new UnexpectedValueException('Partner response is not valid JSON');
}
if (!is_array($data)) {
rejectPayloadContract(
'Partner returned valid JSON, but not an object',
$requestId,
$body
);
}
if (
!array_key_exists('order_id', $data)
|| !is_string($data['order_id'])
|| $data['order_id'] === ''
) {
rejectPayloadContract(
'Partner JSON has no non-empty order_id',
$requestId,
$body
);
}
return $data;
}
Значение json_last_error() читается сразу после json_decode(). Это состояние относится к последней операции JSON, поэтому его легко затереть следующим json_encode() или повторным разбором. В примере в журнал попадают код ошибки, размер тела и хеш. Хеш позволяет сравнить два ответа, не печатая сам ответ в общий журнал. Если отладка требует фрагмент тела, её лучше проводить в ограниченной тестовой среде с маскированием данных.
Не путать формат с договором
Предположим, партнёр ответил []. С точки зрения JSON всё в порядке. Для запроса «найди заказы за час» это может быть нормальный нулевой результат. Для запроса «создай заказ» пустой массив не годится, потому что договор ожидает идентификатор. Это уже не ошибка декодера и не повод говорить, что «API вернул битый JSON». Это нарушение контракта полезной нагрузки.
Такая формулировка помогает и при разговоре с партнёром. Вместо расплывчатого «не распарсили ответ» можно передать факт: HTTP-статус был 200, JSON синтаксически корректен, но поле order_id отсутствует или имеет другой тип. Это сообщение можно проверить на их стороне и закрепить в документации API.
Проверка на четырёх маленьких ответах
Тест не обязан ходить в сеть. Достаточно передать функции строки и сравнить исключение или результат. Важно держать рядом успешный пустой сценарий только для той операции, где пустота допустима: иначе тест сам начнёт размывать договор.
- Передать
{"order_id":"A-17"}и проверить, что функция вернула массив с идентификатором. - Передать
<html>maintenance</html>; ожидается веткаjson_decode_errorс кодомJSON_ERROR_SYNTAX. - Передать
null;json_last_error()должен показать успех разбора, а функция должна отклонить неподходящий тип. - Передать
[]; разбор успешен, но контракт создания заказа должен отклонить отсутствиеorder_id. - Отдельно проверить поиск или список, где
[]является валидным результатом, чтобы не переносить правила одной операции на другую.
Версия PHP и ограничения
В PHP 7.3 появился флаг JSON_THROW_ON_ERROR. В этом примере я намеренно остаюсь на PHP 7.1, поэтому проверка json_last_error() — нормальный механизм для выбранной версии, а не обходной путь. Если проект уже обновлён, исключения могут сделать код компактнее, но проверка формы ответа всё равно остаётся.
Декодер ожидает строку в UTF-8. Ошибка JSON_ERROR_UTF8 говорит о проблеме кодировки, но не объясняет, на каком именно участке она появилась. Для такого случая нужны метрики и безопасный способ сравнить исходные ответы, а не принудительное перекодирование всей строки без понимания источника. RFC 8259 рекомендует уникальные имена в объекте; при повторе разные реализации могут вести себя по-разному. Если поле критично, договор API должен фиксировать его единственность.
Что оставить после исправления
После этой доработки в клиенте остаются два разных события: json_decode_error для невалидного формата и contract_error для валидного, но неожиданного объекта. У них разные причины, разная срочность и разные адресаты. А условие if (!$data) исчезает: оно не способно сказать, что именно произошло.
Проверяемые источники
- PHP manual: json_decode — что возвращает декодер, требование UTF-8 и изменение PHP 7.3
- PHP manual: json_last_error — коды ошибок последней операции JSON
- RFC 8259: JSON — JSON допускает не только объект и массив, но и null, false, true, число и строку