From 317057aeb57ba65a70cf38cf0e180ddea7e280a7 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 4 Sep 2026 01:16:37 +0300 Subject: [PATCH] Editorial: refine article 356 cURL diagnostics --- editorial/agent-rewrites/356.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editorial/agent-rewrites/356.json b/editorial/agent-rewrites/356.json index cefc301..f660195 100644 --- a/editorial/agent-rewrites/356.json +++ b/editorial/agent-rewrites/356.json @@ -3,5 +3,5 @@ "slug": "editorial-2018-02-mechanism-php-diagnostics", "title": "PHP и cURL: как доказать, что интеграция действительно сработала", "excerpt": "Строка от curl_exec() доказывает только получение ответа. Разбираем транспорт, HTTP-статус и контракт тела, чтобы не принять 403 или неверный JSON за успешную операцию.", - "contentHtml": "

Ночной обмен заказами завершился без исключения. Утром в локальной базе нет новых записей. В журнале стоит только «запрос выполнен»: curl_exec() вернул строку. Позже выясняется, что строкой была HTML-страница с кодом 403. Код принял ответ сервера за результат операции. Цена ошибки — потерянная запись, повторная ручная обработка и риск отправить один заказ дважды.

\n

Тезис простой: успешный вызов cURL не равен успешной интеграции. Нужно последовательно подтвердить три факта: передача состоялась, сервер вернул допустимый HTTP-статус, а тело соответствует договору. Каждый факт проверяет свой слой. Если пропустить один слой, диагностика превращается в догадку.

\n

Что именно сообщает curl_exec()

\n

При включённом CURLOPT_RETURNTRANSFER функция возвращает тело ответа, если cURL завершил передачу, и false, если произошла ошибка cURL. Значение false означает проблему транспорта: например, DNS, таймаут или TLS. Оно не описывает ответ приложения партнёра.

\n

HTTP 401, 404 или 500 обычно не становятся ошибкой cURL. Сервер успел ответить, поэтому функция возвращает тело. Статус нужно читать отдельно через curl_getinfo(). Проверка if ($body) смешивает ещё больше случаев: пустую строку, строку \"0\" и false. Используйте строгое сравнение с false.

\n

После статуса остаётся третий вопрос: что лежит в теле. Код 200 может содержать ожидаемый JSON, ошибочный JSON, HTML прокси или корректный JSON без обязательного идентификатора. Протокол завершился, но бизнес-операция ещё не доказана.

\n
\"Схема
Строка от curl_exec() подтверждает получение ответа. Она не подтверждает успех HTTP-запроса и операции.
\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
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 автоматически без гарантии
\n

Минимальный клиент с раздельными ошибками

\n

Ниже учебный пример для 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-параметры могут содержать ключи, подписи и персональные данные. Тело не копируйте в общий журнал. Если расследование требует фрагмента, маскируйте его и ограничивайте отдельным безопасным контуром.

\n

После 2xx проверьте контракт

\n

Статус 2xx означает, что сервер принял запрос на протокольном уровне. Он не говорит, что ответ подходит вашему коду. Например, операция создания заказа может требовать объект с непустым order_id. Ответ {"accepted":true} может быть валидным для другого endpoint, но не для этого.

\n
<?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() нужна явно. В более новых версиях можно выбрать исключения декодера, но проверка типа ответа и обязательных полей всё равно остаётся.

\n

Отрицательный путь: таймаут после записи

\n

Таймаут опаснее явного 500. При 500 сервер сообщил об отказе. При таймауте клиент не знает, успел ли партнёр принять POST. Повтор может создать дубль заказа. Поэтому нельзя ставить одинаковое действие «повторить» на все ошибки.

\n

Повтор допустим только при понятном условии: операция идемпотентна, запрос содержит ключ идемпотентности или API позволяет запросить состояние по внешнему ID. Иначе сохраните неизвестный результат как отдельный класс. Человек или фоновая сверка должны выяснить судьбу первой попытки до новой записи.

\n

Порядок внедрения и проверки

\n
  1. Найдите проверки if (!$body) и замените их на строгое сравнение $body === false.
  2. Соберите curl_errno(), curl_error() и curl_getinfo() сразу после вызова, пока handle открыт.
  3. Разделите ошибки на transport_error и http_error; запишите request ID, статус и время без секретов.
  4. Для каждого 2xx опишите ожидаемый тип тела и обязательные поля. Отдельно решите, допустим ли пустой ответ.
  5. Проверьте учебный endpoint или с помощью mock-сервиса недоступный хост, 401, 404, 500, 200 с неверным JSON и 200 с неверной формой.
  6. Добавьте сценарий таймаута после POST и убедитесь, что код не повторяет запись без ключа идемпотентности или проверки состояния.
\n

Ограничения

\n

Эта схема не определяет, какие статусы считать повторяемыми. Решение зависит от метода, договора и побочных эффектов операции. Она также не решает проблему синтаксической ошибки в коде до запуска, не заменяет мониторинг веб-сервера и не доказывает доступность всех маршрутов партнёра.

\n

Не отключайте проверку TLS ради «успешного» запроса. Не записывайте Authorization, cookie и полный ответ в общий лог. Не называйте 2xx бизнес-успехом, пока код не проверил контракт. Эти ограничения важнее удобства короткой ветки if ($body).

\n

Проверяемый критерий готовности

\n

Диагностика готова, если по каждой из шести проверок видно отдельное решение: транспортная ошибка, HTTP-отказ, неверный JSON, нарушение контракта, подтверждённый успех или неизвестный результат после таймаута. Для каждой записи доступны request ID и время, секреты не попадают в журнал, а повтор POST блокируется без доказанной идемпотентности. Это можно проверить локальным набором ответов, не выдавая учебные результаты за production-наблюдения.

\n

Проверяемые источники

\n" + "contentHtml": "

Ночной обмен заказами завершился без исключения. Утром в локальной базе нет новых записей. В журнале стоит «запрос выполнен»: curl_exec() вернул строку. Позже выясняется, что строкой была HTML-страница с кодом 403. Код принял ответ сервера за результат операции. Цена ошибки — потерянная запись, повторная ручная обработка и риск отправить один заказ дважды.

\n

Чтобы назвать интеграцию успешной, нужно подтвердить три разных факта: передача состоялась, сервер вернул допустимый HTTP-статус, а тело соответствует договору. Каждый факт относится к своему слою. Если пропустить один слой, диагностика превращается в догадку и может запустить повтор там, где состояние уже изменилось.

\n

Что именно сообщает curl_exec()

\n

При включённом CURLOPT_RETURNTRANSFER функция возвращает результат передачи, а при ошибке cURL — false. В первом случае cURL мог получить и полезный ответ, и страницу ошибки. Значение false показывает ошибку выполнения передачи, например DNS, таймаут или TLS; оно не описывает ответ приложения партнёра.

\n

HTTP 401, 404 или 500 обычно не становятся ошибкой cURL. Сервер успел ответить, поэтому функция может вернуть тело. Статус нужно читать отдельно через curl_getinfo(). Проверка if ($body) смешивает ещё больше случаев: пустое тело, строку \"0\" и false. Сначала используйте строгое сравнение с false, затем проверяйте статус и формат тела.

\n

После статуса остаётся третий вопрос: что лежит в теле. Код 200 может содержать ожидаемый JSON, ошибочный JSON, HTML прокси или корректный JSON без обязательного идентификатора. Протокол завершился, но результат операции ещё не доказан.

\n
\"Схема
Положительный результат cURL означает, что библиотека получила ответ. Он ещё не означает, что HTTP-запрос и бизнес-операция завершились успешно.
\n

Симптом → причина → проверка → действие

\n
НаблюдениеКласс сбояЧто проверитьСледующее действие
$body === falseТранспорт или TLScurl_errno, curl_error, URL без секрета, времяПроверить DNS, сертификат, таймаут и доступность хоста
Есть тело, http_code 401 или 403Авторизация или праваHTTP-код, операция, внешний ID, request IDПроверить учётные данные и область доступа; не печатать токен
Есть тело, http_code 404Адрес или версия APIHTTP-код и маршрут без query-параметровСверить путь, метод и версию endpoint
Есть тело, http_code 500Ошибка удалённой стороныHTTP-код, request ID, время и размер телаПередать партнёру ID запроса; не повторять изменяющий запрос вслепую
Статус 2xx, но поле результата отсутствуетНарушен контракт полезной нагрузкиТип тела, JSON-ошибка и обязательные поляОтклонить ответ и открыть разбор контракта
Таймаут после POSTРезультат операции неизвестенИдемпотентный ключ или запрос статуса операцииНе отправлять тот же POST автоматически без гарантии
\n

Минимальный клиент с раздельными ошибками

\n

Ниже учебный пример для 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            'total_time' => $info['total_time'],\n        )));\n    }\n\n    return array(\n        'body' => $body,\n        'content_type' => $info['content_type'],\n        'http_code' => $status,\n        'total_time' => $info['total_time'],\n    );\n}
\n

Снимайте сведения с handle до curl_close(). В диагностическую запись достаточно положить класс ошибки, request ID, статус, размер тела и время. URL нормализуйте: query-параметры могут содержать ключи, подписи или персональные данные. Тело не копируйте в общий журнал. Если расследование требует фрагмента, маскируйте его и ограничивайте отдельным безопасным контуром.

\n

После 2xx проверьте контракт

\n

Статус 2xx означает, что сервер успешно получил, понял и принял запрос на протокольном уровне. Он не говорит, что ответ подходит вашему коду. Например, операция создания заказа может требовать объект с непустым order_id. Ответ {"accepted":true} может быть валидным для другого endpoint, но не для этого. Код 202 вдобавок может означать только постановку работы в очередь.

\n
<?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. При JSON без обязательных полей json_decode() может завершиться успешно, поэтому одной проверки ошибки парсинга недостаточно. Для PHP 7.1 проверка json_last_error() нужна явно; в любом случае остаётся проверка типа ответа и обязательных полей.

\n

Отрицательный путь: таймаут после записи

\n

Таймаут оставляет результат неизвестным: клиент не получил финальный ответ и не может по одному факту таймаута решить, дошёл ли POST до партнёра. Повтор может создать дубль заказа. Поэтому нельзя ставить одинаковое действие «повторить» на все ошибки.

\n

Явный 500 даёт больше информации, чем таймаут, но тоже не доказывает отсутствие побочного эффекта: сервер мог изменить состояние, а затем вернуть ошибку. Для изменяющего запроса повтор допустим только при понятном условии: операция идемпотентна, запрос содержит ключ идемпотентности или API позволяет запросить состояние по внешнему ID. Иначе сохраните результат как требующий сверки до нового запроса.

\n

Воспроизводимая проверка

\n

Для проверки не нужен настоящий партнёр. Достаточно тестового endpoint или mock-сервиса, который по параметру возвращает 200 с корректным JSON, 200 с HTML или сломанным JSON, 401, 404, 500 и отдельный сценарий разрыва соединения. Сравнивайте не только текст исключения, но и поля записи: у каждого сценария должен быть свой kind, а у таймаута — статус «неизвестно», а не «отказ».

\n
  1. Найдите проверки if (!$body) и замените их на строгое сравнение $body === false.
  2. Соберите curl_errno(), curl_error() и curl_getinfo() сразу после вызова, пока handle открыт.
  3. Прогоните недоступный адрес и проверьте ветку transport_error с ненулевым кодом cURL.
  4. Прогоните 401, 404 и 500; у них должна сработать ветка http_error, а не транспортная ошибка.
  5. Прогоните 200 с корректным, но неожиданным телом и 200 с невалидным JSON. Убедитесь, что слой контракта не принимает ни один ответ автоматически.
  6. Добавьте таймаут после POST и проверьте, что код не повторяет запись без ключа идемпотентности или запроса состояния. Для 500 применяйте то же правило, если договор не гарантирует отсутствие побочного эффекта.
\n

Ограничения

\n

Эта схема не определяет, какие статусы считать повторяемыми. Решение зависит от метода, договора и побочных эффектов операции. Она также не решает проблему синтаксической ошибки в коде до запуска, не заменяет мониторинг веб-сервера и не доказывает доступность всех маршрутов партнёра.

\n

Не отключайте проверку TLS ради «успешного» запроса. Не записывайте Authorization, cookie и полный ответ в общий лог. Не называйте 2xx бизнес-успехом, пока код не проверил контракт. Если API возвращает 202, отдельно выясните, означает ли он завершение действия или только постановку в очередь.

\n

Проверяемый критерий готовности

\n

Диагностика готова, если локальный набор ответов различает транспортную ошибку, HTTP-отказ, неверный JSON, нарушение контракта, подтверждённый ответ и неизвестный результат после таймаута. Для каждой записи доступны request ID и время, секреты не попадают в журнал, а повтор изменяющего POST требует доказанной идемпотентности или проверки состояния. В таком виде журнал помогает найти владельца проблемы и не маскирует неопределённость под «запрос выполнен».

\n

Проверяемые источники

\n" }