{ "index": 341, "slug": "editorial-2018-07-mechanism-http-timeouts", "title": "PHP cURL: как понять, на какой стадии сработал таймаут", "excerpt": "Ошибка cURL 28 сообщает о сработавшем ограничении, но не называет стадию запроса. Разбираем connect timeout, общий предел, медленный ответ и условия безопасного повтора.", "contentHtml": "

Во время дежурства оператор увидел страницу заказа и видит знакомый симптом: страница ждёт ответ партнёрского API, PHP-процесс занимает worker, а в журнале появляется только cURL error 28. Команда сначала увеличивает таймаут с десяти до шестидесяти секунд. Ошибка не исчезает. Пользователь ждёт дольше, пул PHP быстрее заполняется, а причина всё ещё неизвестна. Если запрос создаёт заказ или платёж, автоматический повтор может добавить вторую операцию.

\n

Затем таймаут нужно читать как границу стадии, а не как общее название сбоя. CURLOPT_CONNECTTIMEOUT ограничивает фазу соединения. CURLOPT_TIMEOUT ограничивает весь перенос. Пара CURLOPT_LOW_SPEED_LIMIT и CURLOPT_LOW_SPEED_TIME останавливает уже начавшийся слишком медленный перенос. Код 28 может быть итогом любого из этих условий, поэтому одно число в конфигурации не даёт диагноза.

\n

Сценарий: отделяем стадии одного запроса

\n

Сначала libcurl разрешает имя. Затем он устанавливает соединение с узлом и для HTTPS выполняет TLS-рукопожатие. Только после этого приложение партнёра получает шанс обработать HTTP-запрос. Поэтому connect timeout может закончиться на DNS, маршруте, прокси или TLS. Это не доказывает медленный SQL и не доказывает, что партнёр увидел запрос.

\n

После этого libcurl ждёт первый байт ответа. Большая задержка здесь обычно означает очередь, обработчик или другой промежуточный слой. Когда первый байт уже пришёл, начинается получение тела. Тело может идти медленно из-за размера ответа, прокси или канала. Эти случаи требуют разных проверок.

\n
Стадии HTTP-запроса libcurl: соединение, ожидание первого байта и получение тела
Один HTTP-вызов имеет несколько границ. Общий предел охватывает путь целиком, а специальные условия помогают остановить конкретную проблему.
\n

Что именно ограничивают опции cURL

\n

CURLOPT_CONNECTTIMEOUT задаёт предел фазы соединения. Для HTTPS в неё входит TLS. Документация libcurl также относит к этой фазе разрешение имени и переговоры с протоколом или прокси. Значение не прибавляется к общему пределу. Если общий timeout равен двум секундам, а connect timeout — четырём, вызов завершится не позднее двух секунд. Общий предел уже истёк, даже если соединение ещё не установилось.

\n

CURLOPT_TIMEOUT действует от начала до конца переноса. Он включает соединение, ожидание ответа и чтение тела. Это бюджет всего синхронного сценария. Его связывают с тем, сколько времени экран или вызывающий сервис вправе ждать внешний результат. Учебные значения из примера ниже не являются рекомендацией для production.

\n

У обычного вызова нет отдельной универсальной опции «ждать чтения ещё N секунд после первого байта». Пара low speed отвечает на другую задачу. Она завершает перенос, если средняя скорость опускается ниже выбранного порога в течение выбранного времени. Такой порог может остановить зависший поток, но может также прервать легитимно медленную выгрузку. Порог нужно выбирать по размеру ответа и допустимому бюджету, а не по одному неудачному запуску.

\n
<?php\n\n$curl = curl_init('https://partner.example/api/catalog');\ncurl_setopt_array($curl, array(\n    CURLOPT_RETURNTRANSFER => true,\n    CURLOPT_CONNECTTIMEOUT => 2,\n    CURLOPT_TIMEOUT => 8,\n    CURLOPT_LOW_SPEED_LIMIT => 100,\n    CURLOPT_LOW_SPEED_TIME => 3,\n));\n\n$body = curl_exec($curl);\n$errno = curl_errno($curl);\n$error = curl_error($curl);\n$info = curl_getinfo($curl);\nif ($body === false) {\n    // Транспорт завершился ошибкой; используем $errno и $error.\n}\ncurl_close($curl);\n\n// Учебный пример: числа нужно проверить на контракте конкретного API.\n// Код 28 сам по себе не называет стадию таймаута.
\n

Вызов выше задаёт общий бюджет восемь секунд и более короткую границу соединения. Если ответ партнёра начался, но почти застыл, low speed может остановить его раньше общего предела. Смысл настройки виден только вместе с измерениями и журналом. Одного числа в конфигурации недостаточно.

\n

Как читать временную шкалу

\n

После curl_exec вызов curl_getinfo возвращает накопленные отметки времени. CURLINFO_NAMELOOKUP_TIME показывает время от старта до завершения разрешения имени. CURLINFO_CONNECT_TIME — время до установления соединения. Для HTTPS CURLINFO_APPCONNECT_TIME может показать завершение TLS. CURLINFO_STARTTRANSFER_TIME — время до первого байта, полученного libcurl. CURLINFO_TOTAL_TIME — весь перенос.

\n

Это накопленные значения, а не длительности отдельных участков. Если соединение завершилось за 0,20 секунды, а первый байт пришёл за 6,80, ожидание после соединения заняло примерно 6,60 секунды. Вычитание помогает назвать стадию. Нулевой APPCONNECT_TIME для обычного HTTP не означает ошибку: TLS там нет.

\n

Набор полей и точность значений зависят от версии PHP и libcurl. В рабочем trace фиксируйте также версии клиента и библиотеки, чтобы одинаковые названия не скрывали различия окружений. Учебные числа ниже показывают способ рассуждения, а не результат конкретного сервиса.

\n
<?php\n\nfunction transferTimes($curl) {\n    return array(\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    );\n}
\n

Учебный рисунок connect = 0.18, start_transfer = 7.91, total = 8.00 означает: соединение прошло быстро, первый байт не пришёл до общего предела. Рисунок start_transfer = 0.30, total = 8.00 означает другое: тело не завершилось в пределах бюджета. Это примеры формы диагностики, а не результаты конкретного production-сервиса.

\n

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

\n
СимптомВероятная причинаПроверкаДействие
Код 28, HTTP-код 0, не завершился connectDNS, маршрут, TCP или TLSСопоставить NAMELOOKUP, CONNECT, APPCONNECT и проверить узел из той же сетиИсправить адрес или сетевой путь; не увеличивать общий timeout вслепую
Соединение быстрое, первый байт почти у общего пределаОчередь или обработчик партнёраСравнить CONNECT и STARTTRANSFER; проверить серверный request IDРазобрать SLA партнёра или перейти на асинхронную операцию
Первый байт пришёл быстро, тело идёт до лимитаБольшой ответ, прокси или низкая скоростьСравнить STARTTRANSFER и TOTAL, размер тела и LOW_SPEED-порогСократить ответ, получать его частями или изменить обоснованный бюджет
В цепочке получен HTTP-код, но клиент сообщает ошибку контрактаОтвет уже дошёл до клиента; это не транспортный таймаутСохранить статус, заголовки без секретов и тело по правилам маскированияИсправить обработку HTTP-кода; не лечить его настройкой cURL
После 28 хочется повторить POSTСостояние операции неизвестноПроверить идемпотентность, ключ операции и endpoint статусаСначала запросить статус; повторять только по договору API
\n

Лог должен позволять пройти эту таблицу без догадок. Записывайте метод, путь без секретных параметров, корреляционный идентификатор, cURL error, HTTP-код, установленные пороги, размер полученного тела и временные отметки. Пароли, токены и полный чувствительный ответ в общий журнал не кладите.

\n

Почему error 28 не даёт готового диагноза

\n

CURLE_OPERATION_TIMEDOUT означает, что достигнуто условие таймаута. Код не различает DNS, ожидание первого байта и медленное тело. Строка curl_error может дать дополнительное описание, но она тоже не заменяет временную шкалу. Сохраняйте ошибку вместе с конфигурацией вызова. Иначе через неделю нельзя будет понять, какой предел сработал.

\n

HTTP-код равен нулю, если libcurl не получил HTTP-статус от ответной цепочки. Ненулевой код означает только то, что libcurl получил статус; это не подтверждает, что ответ пришёл именно от приложения партнёра: его мог вернуть прокси или другой промежуточный слой. Поэтому 500 и 429 нужно разбирать по HTTP-контракту, а отсутствие статуса — по транспортным данным.

\n

Повторять можно не ошибку, а безопасную операцию

\n

Таймаут завершает наблюдение клиента. Он не доказывает, что сервер не выполнил запрос. Сервер мог сохранить заказ и потерять соединение перед отправкой ответа. Если клиент повторит создание, появится дубль.

\n

Безопасный автоматический повтор требует идемпотентной семантики. GET, HEAD, PUT и DELETE считаются идемпотентными по смыслу HTTP-метода, но конкретный API может добавлять побочные эффекты. POST нельзя считать безопасным только по названию. Его можно повторять, если API документирует ключ операции, дедупликацию и одинаковый результат для повторных попыток. Ключ должен сохраняться между попытками.

\n

Если договор не даёт такой гарантии, после timeout сначала проверяют статус операции по внешнему идентификатору. Если endpoint статуса отсутствует, событие переводят в ручную или отложенную обработку. Увеличение таймаута не решает неизвестное состояние.

\n

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

\n
  1. Опишите бюджет сценария: сколько времени экран или вызывающий сервис может ждать внешний ответ.
  2. Задайте отдельный connect timeout, который меньше либо равен общему пределу.
  3. В тестовой среде воспроизведите отсутствие соединения, задержку до первого байта и медленное тело раздельно.
  4. Для каждого запуска сохраните cURL error, HTTP-код, пороги и NAMELOOKUP, CONNECT, APPCONNECT, STARTTRANSFER, TOTAL.
  5. Сопоставьте временную шкалу с одной стадией. Не меняйте DNS, общий timeout и retry одновременно.
  6. Перед повтором проверьте метод, ключ операции и endpoint статуса.
  7. Измените только подтверждённую границу и повторите тот же тестовый сценарий.
\n

Ограничения и критерий готовности

\n

Времена libcurl описывают путь клиента. Они не показывают внутреннюю очередь партнёра, его базу, работу CDN или прокси. Повторное использование соединения может сделать connect time маленьким. Параллельный вызов имеет отдельную очередь. Эти случаи требуют дополнительных метрик.

\n

Разбор готов, когда для каждого тестового таймаута команда может назвать стадию, показать соответствующие временные отметки и объяснить, почему выбранное действие не создаёт вторую операцию. Для production-настройки дополнительно нужны измеренный размер ответа, допустимый бюджет сценария, безопасный журнал и документированный контракт повтора. Пока хотя бы один из этих пунктов отсутствует, число timeout остаётся предположением.

\n

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

" }