После ночной выгрузки в логе стоит «запрос выполнен», потому что 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.
Что сохранять для каждого уровня
| Наблюдение | Класс сбоя | Что записать в журнал | Следующее действие |
|---|---|---|---|
$body === false | Транспорт или TLS | curl_errno, curl_error, URL без секрета, время | Проверить DNS, сертификат, таймаут и доступность хоста |
Есть тело, http_code 401 или 403 | Авторизация или права | HTTP-код, операция, внешний ID, request ID | Проверить учётные данные и область доступа; не печатать токен |
Есть тело, http_code 404 | Адрес или версия API | HTTP-код и маршрут без 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.
- Включить
CURLOPT_RETURNTRANSFERи заменить все проверкиif (!$body)на строгое$body === false. - Сразу после
curl_exec()собратьcurl_errno,curl_errorиcurl_getinfo, пока handle не закрыт. - Прогнать endpoint с недоступным адресом и проверить ветку
transport_errorс ненулевым кодом cURL. - Прогнать 401, 404 и 500; у них должна сработать ветка
http_error, а не транспортная ошибка. - Прогнать 200 с корректным, но неожиданным телом и убедиться, что следующий слой контракта его не принимает автоматически.
Границы примера
Пример не задаёт универсальные таймауты и не обещает повтор запросов. Он не проверяет сертификаты вручную и не отключает TLS-проверку: если есть ошибка сертификата, её следует увидеть как транспортную причину и исправить настройку окружения. Он также не заменяет лимиты на размер ответа и контроль метода HTTP — эти правила зависят от конкретного API.
Но даже такая небольшая развилка меняет качество диагностики. Вместо одного сообщения «интеграция не работает» появляются три проверяемых факта: передача не состоялась, удалённый сервер ответил не тем статусом или статус нормальный, но тело не прошло контракт. Дальше можно обсуждать решение с человеком, который отвечает именно за этот слой.
Проверяемые источники
- PHP manual: curl_exec — строгое сравнение с false и отличие ошибки cURL от HTTP-статуса
- PHP manual: curl_getinfo — данные последней передачи, включая http_code, content_type и total_time
- PHP manual: curl_errno — код последней ошибки cURL и ноль при отсутствии ошибки
- RFC 7231, раздел 6: Response Status Codes — семантика статус-кодов HTTP на уровне протокола