diff --git a/editorial/agent-rewrites/340.json b/editorial/agent-rewrites/340.json index 0cb0e02..996878b 100644 --- a/editorial/agent-rewrites/340.json +++ b/editorial/agent-rewrites/340.json @@ -2,6 +2,6 @@ "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 он может создать вторую заявку. Цена ошибки — не только пропущенная выгрузка. Система теряет знание о состоянии данных.

\n

Тезис простой: общий таймаут не объясняет причину. Нужна временная шкала одного вызова: NAMELOOKUP_TIME, CONNECT_TIME, APPCONNECT_TIME, STARTTRANSFER_TIME, TOTAL_TIME, код cURL и HTTP-код. Эти поля показывают, что клиент успел увидеть. Они не заменяют логи партнёра, но превращают «зависло» в проверяемую гипотезу.

\n

Сначала фиксируем симптом и границу таймаута

\n

Соберите данные до изменения конфигурации. Запишите безопасный идентификатор операции, метод, путь API, схему и host без секретных параметров, пороги connect и total, curl_errno, curl_error и HTTP-код. Сохраняйте trace и для успешного запроса. Без нормального пути сравнение с ошибкой превращается в догадку.

\n

HTTP-код 0 означает только одно: клиент не получил HTTP-статус. Это не доказательство медленного SQL у партнёра. Ненулевой код означает, что сервер успел прислать статус. Ответ 500 или 429 нужно разбирать по контракту API, а не называть транспортным таймаутом.

\n

Не кладите в общий журнал токен, пароль, полный URL с query-параметрами, тело запроса и полный ответ. Для расследования обычно хватает пути, идентификатора операции, кодов и чисел времени. Если нужен фрагмент тела, заранее определите поля и замаскируйте значения.

\n
\"Временная
Одна ошибка может остановить разные стадии запроса. Повтор зависит от состояния операции, а не от текста ошибки.
\n

Что измеряет PHP cURL

\n

Функция curl_exec возвращает управление после успеха или ошибки. До curl_close можно получить код ошибки и сведения о переносе через curl_getinfo. Поля времени накопительные. Нельзя складывать их как независимые интервалы. STARTTRANSFER_TIME отсчитывается от начала вызова и означает момент получения первого байта, а не момент разбора JSON приложением.

\n
<?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 даже при ошибке. Иначе обработчик оставит только текст исключения и потеряет стадию сбоя.

\n

Разделяем задержки на тестовом стенде

\n

Проверяйте обработчик на локальном сервере. Не ждите, пока настоящий партнёр случайно замедлится. Учебный маршрут ниже создаёт две задержки. Первый маршрут задерживает первый байт. Второй отправляет начало тела и задерживает хвост. Это не модель интернета и не результат production-наблюдений. Цель примера — увидеть разницу между STARTTRANSFER_TIME и TOTAL_TIME на своём клиенте.

\n
<?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-балансировщика.

\n

Читаем временную шкалу

\n
СимптомПричина, которую проверяемПроверкаДействие
HTTP 0, connect близок к пределуDNS, маршрут, TCP или TLS не завершилисьСравнить lookup, connect и app connect; проверить host, DNS, маршрут и сертификатИсправить доступность или передать партнёру точные времена; не увеличивать общий предел вслепую
connect мал, первый байт приходит поздноСоединение установлено, обработчик партнёра не начал ответСопоставить start transfer с connect и отправить ID операции партнёруПроверить очередь и обработчик; изменить предел только после согласования бюджета
Первый байт ранний, total близок к пределуТело медленное, большое или буферизуетсяСравнить размер ответа, скорость и завершение чтения на тестовом стендеУменьшить выборку, разделить выгрузку или настроить передачу по контракту
HTTP 500 или 429Сервер ответил статусомПрочитать код, заголовки и безопасное тело по контрактуПрименить правила API для ошибки, лимита и повтора
\n

Для HTTPS поле APPCONNECT_TIME помогает увидеть завершение TLS. На HTTP оно может быть нулевым. Ноль нельзя трактовать как «TLS занял ноль секунд», если запрос не использует TLS. Время после получения тела — разбор JSON, запись в базу и ответ вызывающему коду — в эту шкалу нужно добавить отдельными измерениями.

\n

Меняем одну границу за раз

\n

Если trace указывает на поздний первый байт, увеличенный timeout лишь дольше скрывает задержку партнёра. Если после уменьшения ответа TOTAL_TIME сократился, вы улучшили передачу, но не доказали, что ускорился обработчик. Если CONNECT_TIME близок к пределу, настройка размера JSON не исправит DNS или TLS.

\n

Сначала воспроизведите тот же сценарий. Затем измените один параметр: адрес, размер ответа, connect timeout или общий timeout. После этого сравните trace по той же стадии. Такой эксперимент отделяет причину от случайного удачного ответа. Не меняйте одновременно DNS, retry, размер ответа и лимиты: результат нельзя будет интерпретировать.

\n

Решаем, можно ли повторять операцию

\n

После таймаута POST /orders клиент не знает, успел ли партнёр создать заказ до обрыва ответа. Повтор может создать второй заказ. Метод POST не становится безопасным только потому, что cURL вернул ошибку. Безопасность повтора задаёт контракт конкретной операции.

\n

Для чтения ограниченный повтор обычно допустим, если 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

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

\n
  1. Зафиксируйте метод, безопасный ID операции, путь, host, пороги, cURL error, HTTP-код и все накопительные времена.
  2. Определите последнюю достигнутую стадию: соединение, первый байт или завершение тела.
  3. Сверьте trace с успешным запросом того же маршрута и с логом партнёра, если он доступен.
  4. Воспроизведите задержку на локальном учебном сервере и убедитесь, что обработчик различает первый байт и тело.
  5. Проверьте одну внешнюю гипотезу: DNS/TLS, ожидание обработчика или размер и скорость ответа.
  6. Для изменяющей операции проверьте ключ и запрос статуса до любого повтора.
  7. Измените один предел или параметр контракта, повторите тот же тест и сравните trace.
\n

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

\n

Клиентский trace не показывает внутреннюю очередь партнёра, его SQL и работу прокси. Повторно используемое соединение может сделать connect коротким, хотя обработчик всё ещё отвечает поздно. Вызов из очереди имеет собственный deadline и собственные повторы. Их нужно учитывать отдельно. Большой ответ может завершить HTTP-перенос, а затем упасть на разборе или записи в базу. Это уже другой участок цепочки.

\n

Разбор готов, когда для одного тестового сценария видны cURL-код, HTTP-код, пороги и временная шкала; для задержки до первого байта и задержки тела есть отдельная проверка; выбранный предел связан с конкретной стадией; а изменяющая операция не повторяется при неизвестном результате без ключа и проверки статуса. Формат trace и маскирование секретов должны пройти проверку владельца интеграции. Production-результат из учебного примера не следует.

\n

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

" + "excerpt": "Ошибка cURL 28 не говорит, где остановился запрос. Разбираем временную шкалу PHP cURL, отличаем сбой соединения от позднего первого байта и выбираем безопасное действие для чтения и операции, которая меняет данные.", + "contentHtml": "

Ночная синхронизация завершилась сообщением cURL error 28. Утром команда видит только «таймаут партнёра». Неизвестно, не ответил DNS, не установился TLS, партнёр не начал ответ или тело уже началось, но не успело передаться. Повтор запуска кажется очевидным. Для POST он может создать вторую заявку. Цена ошибки — не только пропущенная выгрузка: система теряет знание о состоянии данных.

\n

Тезис простой: общий таймаут не объясняет причину. Нужна временная шкала одного вызова: NAMELOOKUP_TIME, CONNECT_TIME, APPCONNECT_TIME, STARTTRANSFER_TIME, TOTAL_TIME, код cURL и HTTP-код. Эти поля показывают, что клиент успел увидеть. Они не заменяют логи партнёра, но превращают «зависло» в проверяемую гипотезу.

\n

Сначала фиксируем симптом и границу таймаута

\n

Соберите данные до изменения конфигурации. Запишите безопасный идентификатор операции, метод, путь API, схему и host без секретных параметров, пороги connect и total, curl_errno, curl_error и HTTP-код. Сохраняйте trace и для успешного запроса. Без нормального пути сравнение с ошибкой превращается в догадку.

\n

HTTP-код 0 означает только одно: клиент не получил HTTP-статус. Это не доказательство медленного SQL у партнёра. Ненулевой код означает, что сервер успел прислать статус. Ответ 500 или 429 нужно разбирать по контракту API, а не называть транспортным таймаутом.

\n

Не кладите в общий журнал токен, пароль, полный URL с query-параметрами, тело запроса и полный ответ. Для расследования обычно хватает пути, идентификатора операции, кодов и чисел времени. Если нужен фрагмент тела, заранее определите поля и замаскируйте значения.

\n
\"Временная
Одна ошибка может остановить разные стадии запроса. Повтор зависит от состояния операции, а не от текста ошибки.
\n

Что измеряет PHP cURL

\n

Функция curl_exec возвращает управление после успеха или ошибки. До curl_close можно получить код ошибки и сведения о переносе через curl_getinfo. Поля времени накопительные: CONNECT_TIME уже включает предыдущую фазу разрешения имени, а STARTTRANSFER_TIME и TOTAL_TIME отсчитываются от начала операции. Нельзя складывать их как независимые интервалы.

\n
<?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$operationId = 'demo-340';\n$curl = curl_init('http://127.0.0.1:8080/slow-first-byte');\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\nvar_dump($trace);
\n

В этом примере URL и идентификатор заданы явно, поэтому формат trace можно повторить без скрытых переменных. Это не готовая библиотека логирования. В production отдельно проверьте версию PHP и libcurl, типы полей и маскирование. После curl_exec сохраните trace даже при ошибке. Иначе обработчик оставит только текст исключения и потеряет стадию сбоя.

\n

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

\n

Разделяем задержки на тестовом стенде

\n

Проверяйте обработчик на локальном сервере. Не ждите, пока настоящий партнёр случайно замедлится. Учебный маршрут ниже создаёт две задержки. Первый маршрут задерживает первый байт. Второй отправляет начало тела и задерживает хвост. Цель примера — увидеть разницу между STARTTRANSFER_TIME и TOTAL_TIME на своём клиенте.

\n
<?php\n// router.php\nob_implicit_flush(true);\nwhile (ob_get_level() > 0) {\n    ob_end_flush();\n}\n\n$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);\nheader('Content-Type: application/json');\nheader('X-Accel-Buffering: no');\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\ncurl -sS -o /dev/null -w 'first=%{time_starttransfer} total=%{time_total}\n' http://127.0.0.1:8080/slow-body
\n

Запустите сервер в одном терминале, а запрос — в другом. Для /slow-first-byte общий предел сработает, если он меньше четырёх секунд. У /slow-body первый байт может прийти быстро, а общий предел сработает позже. flush(), буферы SAPI и прокси влияют на момент доставки, поэтому проверяйте значения именно на своём стенде. Этот пример не доказывает поведение production-балансировщика.

\n

Читаем временную шкалу

\n
СимптомПричина, которую проверяемПроверкаДействие
HTTP 0, connect близок к пределуОдна из стадий установления соединения заняла бюджетСравнить lookup, connect и app connect; проверить host, DNS, маршрут и сертификатИсправить доступность или передать партнёру точные времена; не увеличивать общий предел вслепую
connect мал, первый байт приходит поздноСоединение установлено, обработчик партнёра не начал ответСопоставить start transfer с connect и отправить ID операции партнёруПроверить очередь и обработчик; изменить предел только после согласования бюджета
Первый байт ранний, total близок к пределуТело медленное, большое или буферизуетсяСравнить размер ответа, скорость и завершение чтения на тестовом стендеУменьшить выборку, разделить выгрузку или настроить передачу по контракту
HTTP 500 или 429Сервер ответил статусомПрочитать код, заголовки и безопасное тело по контрактуПрименить правила API для ошибки, лимита и повтора
\n

Для HTTPS поле APPCONNECT_TIME помогает увидеть момент завершения TLS. На HTTP оно может быть нулевым. Ноль нельзя трактовать как «TLS занял ноль секунд», если запрос не использует TLS. Время после получения тела — разбор JSON, запись в базу и ответ вызывающему коду — в эту шкалу нужно добавить отдельными измерениями.

\n

Меняем одну границу за раз

\n

Если trace указывает на поздний первый байт, увеличенный timeout лишь дольше скрывает задержку партнёра. Если после уменьшения ответа TOTAL_TIME сократился, вы улучшили передачу, но не доказали, что ускорился обработчик. Если CONNECT_TIME близок к пределу, настройка размера JSON не исправит DNS или TLS.

\n

Сначала воспроизведите тот же сценарий. Затем измените один параметр: адрес, размер ответа, connect timeout или общий timeout. После этого сравните trace по той же стадии. Такой эксперимент отделяет причину от случайного удачного ответа. Не меняйте одновременно DNS, retry, размер ответа и лимиты: результат нельзя будет интерпретировать.

\n

Решаем, можно ли повторять операцию

\n

После таймаута POST /orders клиент не знает, успел ли партнёр создать заказ до обрыва ответа. Повтор может создать второй заказ. Метод POST не становится безопасным только потому, что cURL вернул ошибку. Безопасность повтора задаёт контракт конкретной операции.

\n

Для безопасного или идемпотентного чтения ограниченный повтор допустим, если API допускает его и общий бюджет не исчерпан. Для изменения состояния нужен постоянный ключ операции и документированная дедупликация у партнёра. Если ключ есть, после неизвестного результата сначала запросите статус по тому же ключу. Если ключа и проверки статуса нет, пометьте результат как неопределённый. Не запускайте второй create-вызов автоматически.

\n
<?php\n\nfunction actionAfterTimeout($method, $hasOperationKey, $canCheckStatus) {\n    $method = strtoupper($method);\n\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\n// POST без ключа и проверки статуса не повторяем.
\n

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

\n

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

\n
  1. Зафиксируйте метод, безопасный ID операции, путь, host, пороги, cURL error, HTTP-код и все накопительные времена.
  2. Определите последнюю достигнутую стадию: соединение, первый байт или завершение тела.
  3. Сверьте trace с успешным запросом того же маршрута и с логом партнёра, если он доступен.
  4. Воспроизведите задержку на локальном учебном сервере и убедитесь, что обработчик различает первый байт и тело.
  5. Проверьте одну внешнюю гипотезу: DNS/TLS, ожидание обработчика или размер и скорость ответа.
  6. Для изменяющей операции проверьте ключ и запрос статуса до любого повтора.
  7. Измените один предел или параметр контракта, повторите тот же тест и сравните trace.
\n

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

\n

Клиентский trace не показывает внутреннюю очередь партнёра, его SQL и работу прокси. Повторно используемое соединение может сделать connect коротким, хотя обработчик всё ещё отвечает поздно. Вызов из очереди имеет собственный deadline и собственные повторы. Их нужно учитывать отдельно. Большой ответ может завершить HTTP-перенос, а затем упасть на разборе или записи в базу. Это уже другой участок цепочки.

\n

Разбор готов, когда для одного тестового сценария видны cURL-код, HTTP-код, пороги и временная шкала; для задержки до первого байта и задержки тела есть отдельная проверка; выбранный предел связан с конкретной стадией; а изменяющая операция не повторяется при неизвестном результате без ключа и проверки статуса. Формат trace и маскирование секретов должны пройти проверку владельца интеграции. Production-результат из учебного примера не следует.

\n

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

" }