{ "index": 340, "slug": "editorial-2018-07-field-http-timeouts", "title": "PHP cURL: как найти стадию HTTP-таймаута и не повторить операцию дважды", "excerpt": "Ошибка cURL 28 не говорит, где остановился запрос. Разбираем временную шкалу PHP cURL, отличаем сбой соединения от позднего первого байта и выбираем безопасное действие для повторяемой и изменяющей операции.", "contentHtml": "
Ночная синхронизация завершилась сообщением cURL error 28. Утром команда видит только «таймаут партнёра». Неизвестно, не ответил DNS, не установился TLS, партнёр не начал ответ или тело уже началось, но не успело передаться. Повтор запуска кажется очевидным. Для POST он может создать вторую заявку. Цена ошибки — не только пропущенная выгрузка. Система теряет знание о состоянии данных.
Тезис простой: общий таймаут не объясняет причину. Нужна временная шкала одного вызова: NAMELOOKUP_TIME, CONNECT_TIME, APPCONNECT_TIME, STARTTRANSFER_TIME, TOTAL_TIME, код cURL и HTTP-код. Эти поля показывают, что клиент успел увидеть. Они не заменяют логи партнёра, но превращают «зависло» в проверяемую гипотезу.
Соберите данные до изменения конфигурации. Запишите безопасный идентификатор операции, метод, путь API, схему и host без секретных параметров, пороги connect и total, curl_errno, curl_error и HTTP-код. Сохраняйте trace и для успешного запроса. Без нормального пути сравнение с ошибкой превращается в догадку.
HTTP-код 0 означает только одно: клиент не получил HTTP-статус. Это не доказательство медленного SQL у партнёра. Ненулевой код означает, что сервер успел прислать статус. Ответ 500 или 429 нужно разбирать по контракту API, а не называть транспортным таймаутом.
Не кладите в общий журнал токен, пароль, полный URL с query-параметрами, тело запроса и полный ответ. Для расследования обычно хватает пути, идентификатора операции, кодов и чисел времени. Если нужен фрагмент тела, заранее определите поля и замаскируйте значения.
\nФункция curl_exec возвращает управление после успеха или ошибки. До curl_close можно получить код ошибки и сведения о переносе через curl_getinfo. Поля времени накопительные. Нельзя складывать их как независимые интервалы. STARTTRANSFER_TIME отсчитывается от начала вызова и означает момент получения первого байта, а не момент разбора JSON приложением.
<?php\n\nfunction traceCurl($curl, $operationId, array $limits) {\n return array(\n 'operation_id' => $operationId,\n 'curl_errno' => curl_errno($curl),\n 'curl_error' => curl_error($curl),\n 'http_code' => curl_getinfo($curl, CURLINFO_HTTP_CODE),\n 'name_lookup' => curl_getinfo($curl, CURLINFO_NAMELOOKUP_TIME),\n 'connect' => curl_getinfo($curl, CURLINFO_CONNECT_TIME),\n 'app_connect' => curl_getinfo($curl, CURLINFO_APPCONNECT_TIME),\n 'start_transfer' => curl_getinfo($curl, CURLINFO_STARTTRANSFER_TIME),\n 'total' => curl_getinfo($curl, CURLINFO_TOTAL_TIME),\n 'connect_limit' => $limits['connect'],\n 'total_limit' => $limits['total'],\n );\n}\n\n$limits = array('connect' => 2, 'total' => 8);\n$curl = curl_init($partnerUrl);\ncurl_setopt_array($curl, array(\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_CONNECTTIMEOUT => $limits['connect'],\n CURLOPT_TIMEOUT => $limits['total'],\n));\n\n$body = curl_exec($curl);\n$trace = traceCurl($curl, $operationId, $limits);\ncurl_close($curl);\nКод показывает формат учебного trace, а не готовую библиотеку логирования. В production отдельно проверьте версию PHP и libcurl, типы полей и маскирование. После curl_exec сохраните trace даже при ошибке. Иначе обработчик оставит только текст исключения и потеряет стадию сбоя.
Проверяйте обработчик на локальном сервере. Не ждите, пока настоящий партнёр случайно замедлится. Учебный маршрут ниже создаёт две задержки. Первый маршрут задерживает первый байт. Второй отправляет начало тела и задерживает хвост. Это не модель интернета и не результат production-наблюдений. Цель примера — увидеть разницу между STARTTRANSFER_TIME и TOTAL_TIME на своём клиенте.
<?php\n// router.php\n$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);\nheader('Content-Type: application/json');\n\nif ($path === '/slow-first-byte') {\n usleep(4000000);\n echo json_encode(array('ok' => true));\n return;\n}\n\nif ($path === '/slow-body') {\n echo '{\"items\":[';\n flush();\n usleep(4000000);\n echo '1]}';\n return;\n}\n\necho json_encode(array('ok' => true));\nЗапустите учебный сервер командой php -S 127.0.0.1:8080 router.php. Вызов /slow-first-byte должен превысить общий предел, если он меньше четырёх секунд. У /slow-body первый байт может прийти быстро, а общий предел сработает позже. Поведение flush() зависит от SAPI и прокси. Поэтому проверяйте этот пример локально и не используйте его как доказательство поведения production-балансировщика.
| Симптом | Причина, которую проверяем | Проверка | Действие |
|---|---|---|---|
| HTTP 0, connect близок к пределу | DNS, маршрут, TCP или TLS не завершились | Сравнить lookup, connect и app connect; проверить host, DNS, маршрут и сертификат | Исправить доступность или передать партнёру точные времена; не увеличивать общий предел вслепую |
| connect мал, первый байт приходит поздно | Соединение установлено, обработчик партнёра не начал ответ | Сопоставить start transfer с connect и отправить ID операции партнёру | Проверить очередь и обработчик; изменить предел только после согласования бюджета |
| Первый байт ранний, total близок к пределу | Тело медленное, большое или буферизуется | Сравнить размер ответа, скорость и завершение чтения на тестовом стенде | Уменьшить выборку, разделить выгрузку или настроить передачу по контракту |
| HTTP 500 или 429 | Сервер ответил статусом | Прочитать код, заголовки и безопасное тело по контракту | Применить правила API для ошибки, лимита и повтора |
Для HTTPS поле APPCONNECT_TIME помогает увидеть завершение TLS. На HTTP оно может быть нулевым. Ноль нельзя трактовать как «TLS занял ноль секунд», если запрос не использует TLS. Время после получения тела — разбор JSON, запись в базу и ответ вызывающему коду — в эту шкалу нужно добавить отдельными измерениями.
Если trace указывает на поздний первый байт, увеличенный timeout лишь дольше скрывает задержку партнёра. Если после уменьшения ответа TOTAL_TIME сократился, вы улучшили передачу, но не доказали, что ускорился обработчик. Если CONNECT_TIME близок к пределу, настройка размера JSON не исправит DNS или TLS.
Сначала воспроизведите тот же сценарий. Затем измените один параметр: адрес, размер ответа, connect timeout или общий timeout. После этого сравните trace по той же стадии. Такой эксперимент отделяет причину от случайного удачного ответа. Не меняйте одновременно DNS, retry, размер ответа и лимиты: результат нельзя будет интерпретировать.
\nПосле таймаута POST /orders клиент не знает, успел ли партнёр создать заказ до обрыва ответа. Повтор может создать второй заказ. Метод POST не становится безопасным только потому, что cURL вернул ошибку. Безопасность повтора задаёт контракт конкретной операции.
Для чтения ограниченный повтор обычно допустим, если API допускает его и общий бюджет не исчерпан. Для изменения состояния нужен постоянный ключ операции и документированная дедупликация у партнёра. Если ключ есть, после неизвестного результата сначала запросите статус по тому же ключу. Если ключа и проверки статуса нет, пометьте результат как неопределённый. Не запускайте второй create-вызов автоматически.
\n<?php\n\nfunction actionAfterTimeout($method, $hasOperationKey, $canCheckStatus) {\n if ($method === 'GET' || $method === 'HEAD') {\n return 'one_limited_retry';\n }\n\n if ($hasOperationKey && $canCheckStatus) {\n return 'check_operation_status';\n }\n\n return 'mark_result_unknown';\n}\nЭто учебная развилка. Она не заменяет описание API, лимиты повторов, дедупликацию и требования к очереди. Для денежных операций, заказов и других необратимых действий решение должен подтверждать владелец контракта.
\nКлиентский trace не показывает внутреннюю очередь партнёра, его SQL и работу прокси. Повторно используемое соединение может сделать connect коротким, хотя обработчик всё ещё отвечает поздно. Вызов из очереди имеет собственный deadline и собственные повторы. Их нужно учитывать отдельно. Большой ответ может завершить HTTP-перенос, а затем упасть на разборе или записи в базу. Это уже другой участок цепочки.
\nРазбор готов, когда для одного тестового сценария видны cURL-код, HTTP-код, пороги и временная шкала; для задержки до первого байта и задержки тела есть отдельная проверка; выбранный предел связан с конкретной стадией; а изменяющая операция не повторяется при неизвестном результате без ключа и проверки статуса. Формат trace и маскирование секретов должны пройти проверку владельца интеграции. Production-результат из учебного примера не следует.
\n