DarkRiDDeR10 мин

PHP и cURL. Почему curl_exec() не означает успех интеграции

PHPcURLИнтеграции

После ночной выгрузки в логе стоит «запрос выполнен», потому что curl_exec() вернул строку. Утром выясняется, что строкой была HTML-страница с 403, а заказы не дошли. Ошибка в проверке не синтаксическая: код спросил cURL только о доставке ответа, а бизнес-код сделал вывод о результате всей операции. Разберём один вопрос: какой минимальный набор проверок отличает сетевой сбой, HTTP-отказ и рабочий ответ партнёра?

У одного вызова три разных результата

При включённом CURLOPT_RETURNTRANSFER функция curl_exec() возвращает тело ответа при успехе cURL и false при его ошибке. Проверять результат надо строгим сравнением: непустое тело может быть строкой "0", которая в обычном условии ведёт себя как ложь. Главное здесь другое: статус 404 или 500 сам по себе не считается ошибкой cURL. Документация прямо предлагает читать HTTP-статус через curl_getinfo(). Цена ошибки — записать страницу отказа как успешный ответ и отправить дальше неверные данные.

Отсюда порядок проверки. Сначала узнаём, состоялась ли передача: $body === false, curl_errno() и curl_error(). Затем читаем http_code, тип содержимого и время из curl_getinfo(). Только после этого разбираем тело как JSON или иной формат, который обещан договором с партнёром. Если смешать уровни, журнал начинает сообщать «ошибка API» и для DNS, и для 401, и для сломанного JSON.

Диаграмма классификации ответа cURL: false ведёт к транспортной ошибке; строка проверяется по HTTP-коду, затем по контракту тела
Положительный результат cURL означает, что библиотека получила ответ. Он ещё не означает, что HTTP-запрос и бизнес-операция завершились успешно.

Что сохранять для каждого уровня

НаблюдениеКласс сбояЧто записать в журналСледующее действие
$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 и ожидаемое телоТранспорт и HTTP прошлиКод, размер и время ответаПроверить обязательные поля тела перед изменением локальных данных

Клиент, который не прячет уровень ошибки

В примере ниже нет общего «интеграционного клиента». Нужна маленькая функция, которую легко вызвать в изолированном скрипте и легко покрыть разными ответами. Время соединения и общий таймаут здесь проектные: их надо выбирать под договорённость с конкретным сервисом, а не переносить числа из чужого кода.

<?php

function requestPartner($url, $requestId)
{
    $handle = curl_init($url);

    curl_setopt_array($handle, array(
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 3,
        CURLOPT_TIMEOUT => 10,
        CURLOPT_HTTPHEADER => array(
            'Accept: application/json',
            'X-Request-Id: ' . $requestId,
        ),
    ));

    $body = curl_exec($handle);
    $curlErrno = curl_errno($handle);
    $curlError = curl_error($handle);
    $info = curl_getinfo($handle);
    curl_close($handle);

    if ($body === false) {
        throw new RuntimeException(json_encode(array(
            'kind' => 'transport_error',
            'request_id' => $requestId,
            'curl_errno' => $curlErrno,
            'curl_error' => $curlError,
            'total_time' => $info['total_time'],
        )));
    }

    $status = (int) $info['http_code'];
    if ($status < 200 || $status >= 300) {
        throw new RuntimeException(json_encode(array(
            'kind' => 'http_error',
            'request_id' => $requestId,
            'http_code' => $status,
            'content_type' => $info['content_type'],
            'body_bytes' => strlen($body),
            'total_time' => $info['total_time'],
        )));
    }

    return array(
        'body' => $body,
        'content_type' => $info['content_type'],
        'http_code' => $status,
        'total_time' => $info['total_time'],
    );
}

Функция намеренно не пишет URL целиком. Query-параметры часто содержат ключи, подписи или персональные идентификаторы. Если адрес нужен для расследования, лучше сохранить имя интеграции и заранее нормализованный путь. Тело ответа тоже не стоит бездумно добавлять к исключению: для первичного поиска достаточно размера, типа содержимого и ID операции; безопасный фрагмент можно сохранить отдельно в тестовой среде.

Почему 2xx — ещё не результат операции

HTTP-код описывает ответ сервера на протокольном уровне. Он не может за нас подтвердить, что партнёр принял заказ в нужном виде. Один API возвращает {"id":"A-17"}, другой — {"accepted":true}, третий ставит задачу в очередь. Поэтому после 2xx должна быть проверка конкретного поля, которое выбрано в контракте. Разбор JSON и формы ответа — отдельная граница; её нельзя заменять условием if ($body).

Это же объясняет, почему автоматический повтор записи нельзя включать как реакцию на любой сбой. При таймауте неизвестно, дошёл ли запрос до партнёра. Если операция создаёт заказ, второй POST может создать дубликат. Повтор становится безопасным только когда протокол даёт ключ идемпотентности, внешний ID или отдельный способ узнать результат первой попытки. Пока такого условия нет, в журнале должна появиться операция для разбора, а не второй запрос в фоне.

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

Для проверки не нужен настоящий партнёр. Достаточно небольшого тестового endpoint, который по параметру возвращает 200 с JSON, 401, 500 и закрывает соединение. Важно сравнивать не только текст исключения, но и поля записи: у каждого сценария должен быть свой kind. Тогда мониторинг может отдельно считать транспортные сбои и ответы 5xx.

  1. Включить CURLOPT_RETURNTRANSFER и заменить все проверки if (!$body) на строгое $body === false.
  2. Сразу после curl_exec() собрать curl_errno, curl_error и curl_getinfo, пока handle не закрыт.
  3. Прогнать endpoint с недоступным адресом и проверить ветку transport_error с ненулевым кодом cURL.
  4. Прогнать 401, 404 и 500; у них должна сработать ветка http_error, а не транспортная ошибка.
  5. Прогнать 200 с корректным, но неожиданным телом и убедиться, что следующий слой контракта его не принимает автоматически.

Границы примера

Пример не задаёт универсальные таймауты и не обещает повтор запросов. Он не проверяет сертификаты вручную и не отключает TLS-проверку: если есть ошибка сертификата, её следует увидеть как транспортную причину и исправить настройку окружения. Он также не заменяет лимиты на размер ответа и контроль метода HTTP — эти правила зависят от конкретного API.

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

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