{ "index": 356, "slug": "editorial-2018-02-mechanism-php-diagnostics", "title": "PHP и cURL: как доказать, что интеграция действительно сработала", "excerpt": "Строка от curl_exec() доказывает только получение ответа. Разбираем транспорт, HTTP-статус и контракт тела, чтобы не принять 403 или неверный JSON за успешную операцию.", "contentHtml": "
Ночной обмен заказами завершился без исключения. Утром в локальной базе нет новых записей. В журнале стоит только «запрос выполнен»: curl_exec() вернул строку. Позже выясняется, что строкой была HTML-страница с кодом 403. Код принял ответ сервера за результат операции. Цена ошибки — потерянная запись, повторная ручная обработка и риск отправить один заказ дважды.
Тезис простой: успешный вызов cURL не равен успешной интеграции. Нужно последовательно подтвердить три факта: передача состоялась, сервер вернул допустимый HTTP-статус, а тело соответствует договору. Каждый факт проверяет свой слой. Если пропустить один слой, диагностика превращается в догадку.
\nПри включённом CURLOPT_RETURNTRANSFER функция возвращает тело ответа, если cURL завершил передачу, и false, если произошла ошибка cURL. Значение false означает проблему транспорта: например, DNS, таймаут или TLS. Оно не описывает ответ приложения партнёра.
HTTP 401, 404 или 500 обычно не становятся ошибкой cURL. Сервер успел ответить, поэтому функция возвращает тело. Статус нужно читать отдельно через curl_getinfo(). Проверка if ($body) смешивает ещё больше случаев: пустую строку, строку \"0\" и false. Используйте строгое сравнение с false.
После статуса остаётся третий вопрос: что лежит в теле. Код 200 может содержать ожидаемый JSON, ошибочный JSON, HTML прокси или корректный JSON без обязательного идентификатора. Протокол завершился, но бизнес-операция ещё не доказана.
\ncurl_exec() подтверждает получение ответа. Она не подтверждает успех HTTP-запроса и операции.| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
curl_exec() вернул false | Передача не завершилась | curl_errno(), curl_error(), время и адрес хоста | Проверить DNS, таймаут, TLS и доступность сервиса |
| Есть тело, статус 401 или 403 | Нет прав или истекли учётные данные | http_code, имя операции и request ID | Проверить токен и область доступа; не печатать секрет |
| Есть тело, статус 404 | Неверен маршрут или версия API | Метод, нормализованный путь и версию endpoint | Сверить URL с договором партнёра |
| Есть тело, статус 500 | Ошибка на удалённой стороне | Статус, время, request ID и размер тела | Передать партнёру идентификатор; не повторять запись вслепую |
| Статус 2xx, но поле результата отсутствует | Нарушен контракт полезной нагрузки | Тип тела, JSON-ошибка и обязательные поля | Отклонить ответ и открыть разбор контракта |
| Таймаут после POST | Результат операции неизвестен | Идемпотентный ключ или запрос статуса операции | Не отправлять тот же POST автоматически без гарантии |
Ниже учебный пример для PHP с cURL. Он не задаёт универсальные таймауты и не заменяет правила конкретного API. Его задача — показать границы классификации. Для production-кода добавьте лимит размера ответа, нужный метод, авторизацию и логгер проекта.
\n<?php\n\nfunction requestPartner($url, $requestId)\n{\n $handle = curl_init($url);\n\n curl_setopt_array($handle, array(\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_CONNECTTIMEOUT => 3,\n CURLOPT_TIMEOUT => 10,\n CURLOPT_HTTPHEADER => array(\n 'Accept: application/json',\n 'X-Request-Id: ' . $requestId,\n ),\n ));\n\n $body = curl_exec($handle);\n $curlErrno = curl_errno($handle);\n $curlError = curl_error($handle);\n $info = curl_getinfo($handle);\n curl_close($handle);\n\n if ($body === false) {\n throw new RuntimeException(json_encode(array(\n 'kind' => 'transport_error',\n 'request_id' => $requestId,\n 'curl_errno' => $curlErrno,\n 'curl_error' => $curlError,\n 'total_time' => $info['total_time'],\n )));\n }\n\n $status = (int) $info['http_code'];\n if ($status < 200 || $status >= 300) {\n throw new RuntimeException(json_encode(array(\n 'kind' => 'http_error',\n 'request_id' => $requestId,\n 'http_code' => $status,\n 'content_type' => $info['content_type'],\n 'body_bytes' => strlen($body),\n )));\n }\n\n return array(\n 'body' => $body,\n 'http_code' => $status,\n 'content_type' => $info['content_type'],\n 'total_time' => $info['total_time'],\n );\n}\nСнимайте сведения с handle до curl_close(). В диагностическую запись достаточно положить класс ошибки, request ID, статус, размер тела и время. URL нормализуйте: query-параметры могут содержать ключи, подписи и персональные данные. Тело не копируйте в общий журнал. Если расследование требует фрагмента, маскируйте его и ограничивайте отдельным безопасным контуром.
Статус 2xx означает, что сервер принял запрос на протокольном уровне. Он не говорит, что ответ подходит вашему коду. Например, операция создания заказа может требовать объект с непустым order_id. Ответ {"accepted":true} может быть валидным для другого endpoint, но не для этого.
<?php\n\nfunction decodeCreatedOrder($body)\n{\n $data = json_decode($body, true);\n\n if (json_last_error() !== JSON_ERROR_NONE) {\n throw new UnexpectedValueException('Response is not valid JSON');\n }\n\n if (!is_array($data)\n || !isset($data['order_id'])\n || !is_string($data['order_id'])\n || $data['order_id'] === ''\n ) {\n throw new UnexpectedValueException('Response has no order_id');\n }\n\n return $data;\n}\nЭто тоже учебный пример. Он показывает порядок, а не готовую схему валидации для всех API. Для PHP 7.1 проверка json_last_error() нужна явно. В более новых версиях можно выбрать исключения декодера, но проверка типа ответа и обязательных полей всё равно остаётся.
Таймаут опаснее явного 500. При 500 сервер сообщил об отказе. При таймауте клиент не знает, успел ли партнёр принять POST. Повтор может создать дубль заказа. Поэтому нельзя ставить одинаковое действие «повторить» на все ошибки.
\nПовтор допустим только при понятном условии: операция идемпотентна, запрос содержит ключ идемпотентности или API позволяет запросить состояние по внешнему ID. Иначе сохраните неизвестный результат как отдельный класс. Человек или фоновая сверка должны выяснить судьбу первой попытки до новой записи.
\nif (!$body) и замените их на строгое сравнение $body === false.curl_errno(), curl_error() и curl_getinfo() сразу после вызова, пока handle открыт.transport_error и http_error; запишите request ID, статус и время без секретов.Эта схема не определяет, какие статусы считать повторяемыми. Решение зависит от метода, договора и побочных эффектов операции. Она также не решает проблему синтаксической ошибки в коде до запуска, не заменяет мониторинг веб-сервера и не доказывает доступность всех маршрутов партнёра.
\nНе отключайте проверку TLS ради «успешного» запроса. Не записывайте Authorization, cookie и полный ответ в общий лог. Не называйте 2xx бизнес-успехом, пока код не проверил контракт. Эти ограничения важнее удобства короткой ветки if ($body).
Диагностика готова, если по каждой из шести проверок видно отдельное решение: транспортная ошибка, HTTP-отказ, неверный JSON, нарушение контракта, подтверждённый успех или неизвестный результат после таймаута. Для каждой записи доступны request ID и время, секреты не попадают в журнал, а повтор POST блокируется без доказанной идемпотентности. Это можно проверить локальным набором ответов, не выдавая учебные результаты за production-наблюдения.
\n