8 lines
20 KiB
JSON
8 lines
20 KiB
JSON
{
|
||
"index": 340,
|
||
"slug": "editorial-2018-07-field-http-timeouts",
|
||
"title": "PHP cURL: как найти стадию HTTP-таймаута и не повторить операцию дважды",
|
||
"excerpt": "Ошибка cURL 28 не говорит, где остановился запрос. Разбираем временную шкалу PHP cURL, отличаем сбой соединения от позднего первого байта и выбираем безопасное действие для чтения и операции, которая меняет данные.",
|
||
"contentHtml": "<p>Ночная синхронизация завершилась сообщением <code>cURL error 28</code>. Утром команда видит только «таймаут партнёра». Неизвестно, не ответил DNS, не установился TLS, партнёр не начал ответ или тело уже началось, но не успело передаться. Повтор запуска кажется очевидным. Для <code>POST</code> он может создать вторую заявку. Цена ошибки — не только пропущенная выгрузка: система теряет знание о состоянии данных.</p>\n<p>Тезис простой: общий таймаут не объясняет причину. Нужна временная шкала одного вызова: <code>NAMELOOKUP_TIME</code>, <code>CONNECT_TIME</code>, <code>APPCONNECT_TIME</code>, <code>STARTTRANSFER_TIME</code>, <code>TOTAL_TIME</code>, код cURL и HTTP-код. Эти поля показывают, что клиент успел увидеть. Они не заменяют логи партнёра, но превращают «зависло» в проверяемую гипотезу.</p>\n<h2>Сначала фиксируем симптом и границу таймаута</h2>\n<p>Соберите данные до изменения конфигурации. Запишите безопасный идентификатор операции, метод, путь API, схему и host без секретных параметров, пороги <code>connect</code> и <code>total</code>, <code>curl_errno</code>, <code>curl_error</code> и HTTP-код. Сохраняйте trace и для успешного запроса. Без нормального пути сравнение с ошибкой превращается в догадку.</p>\n<p>HTTP-код <code>0</code> означает только одно: клиент не получил HTTP-статус. Это не доказательство медленного SQL у партнёра. Ненулевой код означает, что сервер успел прислать статус. Ответ <code>500</code> или <code>429</code> нужно разбирать по контракту API, а не называть транспортным таймаутом.</p>\n<p>Не кладите в общий журнал токен, пароль, полный URL с query-параметрами, тело запроса и полный ответ. Для расследования обычно хватает пути, идентификатора операции, кодов и чисел времени. Если нужен фрагмент тела, заранее определите поля и замаскируйте значения.</p>\n<figure><img src=\"/assets/editorial/2018/http-timeout-investigation-2018.svg\" alt=\"Временная шкала разбора HTTP-таймаута: соединение, первый байт, тело ответа и решение о повторе\" /><figcaption>Одна ошибка может остановить разные стадии запроса. Повтор зависит от состояния операции, а не от текста ошибки.</figcaption></figure>\n<h2>Что измеряет PHP cURL</h2>\n<p>Функция <code>curl_exec</code> возвращает управление после успеха или ошибки. До <code>curl_close</code> можно получить код ошибки и сведения о переносе через <code>curl_getinfo</code>. Поля времени накопительные: <code>CONNECT_TIME</code> уже включает предыдущую фазу разрешения имени, а <code>STARTTRANSFER_TIME</code> и <code>TOTAL_TIME</code> отсчитываются от начала операции. Нельзя складывать их как независимые интервалы.</p>\n<pre><code><?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);</code></pre>\n<p>В этом примере URL и идентификатор заданы явно, поэтому формат trace можно повторить без скрытых переменных. Это не готовая библиотека логирования. В production отдельно проверьте версию PHP и libcurl, типы полей и маскирование. После <code>curl_exec</code> сохраните trace даже при ошибке. Иначе обработчик оставит только текст исключения и потеряет стадию сбоя.</p>\n<p><code>CURLOPT_CONNECTTIMEOUT</code> ограничивает только фазу установления соединения: в неё входят разрешение имени и согласования протокола. <code>CURLOPT_TIMEOUT</code> задаёт общий предел от начала до конца переноса. Первый предел входит во второй, поэтому значение <code>total</code> должно быть не меньше реального бюджета операции, а не просто «ещё одним таймаутом».</p>\n<h2>Разделяем задержки на тестовом стенде</h2>\n<p>Проверяйте обработчик на локальном сервере. Не ждите, пока настоящий партнёр случайно замедлится. Учебный маршрут ниже создаёт две задержки. Первый маршрут задерживает первый байт. Второй отправляет начало тела и задерживает хвост. Цель примера — увидеть разницу между <code>STARTTRANSFER_TIME</code> и <code>TOTAL_TIME</code> на своём клиенте.</p>\n<pre><code><?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));</code></pre>\n<pre><code>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</code></pre>\n<p>Запустите сервер в одном терминале, а запрос — в другом. Для <code>/slow-first-byte</code> общий предел сработает, если он меньше четырёх секунд. У <code>/slow-body</code> первый байт может прийти быстро, а общий предел сработает позже. <code>flush()</code>, буферы SAPI и прокси влияют на момент доставки, поэтому проверяйте значения именно на своём стенде. Этот пример не доказывает поведение production-балансировщика.</p>\n<h2>Читаем временную шкалу</h2>\n<div class=\"table-scroll\"><table><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина, которую проверяем</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>HTTP 0, connect близок к пределу</td><td>Одна из стадий установления соединения заняла бюджет</td><td>Сравнить lookup, connect и app connect; проверить host, DNS, маршрут и сертификат</td><td>Исправить доступность или передать партнёру точные времена; не увеличивать общий предел вслепую</td></tr><tr><td>connect мал, первый байт приходит поздно</td><td>Соединение установлено, обработчик партнёра не начал ответ</td><td>Сопоставить start transfer с connect и отправить ID операции партнёру</td><td>Проверить очередь и обработчик; изменить предел только после согласования бюджета</td></tr><tr><td>Первый байт ранний, total близок к пределу</td><td>Тело медленное, большое или буферизуется</td><td>Сравнить размер ответа, скорость и завершение чтения на тестовом стенде</td><td>Уменьшить выборку, разделить выгрузку или настроить передачу по контракту</td></tr><tr><td>HTTP 500 или 429</td><td>Сервер ответил статусом</td><td>Прочитать код, заголовки и безопасное тело по контракту</td><td>Применить правила API для ошибки, лимита и повтора</td></tr></tbody></table></div>\n<p>Для HTTPS поле <code>APPCONNECT_TIME</code> помогает увидеть момент завершения TLS. На HTTP оно может быть нулевым. Ноль нельзя трактовать как «TLS занял ноль секунд», если запрос не использует TLS. Время после получения тела — разбор JSON, запись в базу и ответ вызывающему коду — в эту шкалу нужно добавить отдельными измерениями.</p>\n<h2>Меняем одну границу за раз</h2>\n<p>Если trace указывает на поздний первый байт, увеличенный timeout лишь дольше скрывает задержку партнёра. Если после уменьшения ответа <code>TOTAL_TIME</code> сократился, вы улучшили передачу, но не доказали, что ускорился обработчик. Если <code>CONNECT_TIME</code> близок к пределу, настройка размера JSON не исправит DNS или TLS.</p>\n<p>Сначала воспроизведите тот же сценарий. Затем измените один параметр: адрес, размер ответа, connect timeout или общий timeout. После этого сравните trace по той же стадии. Такой эксперимент отделяет причину от случайного удачного ответа. Не меняйте одновременно DNS, retry, размер ответа и лимиты: результат нельзя будет интерпретировать.</p>\n<h2>Решаем, можно ли повторять операцию</h2>\n<p>После таймаута <code>POST /orders</code> клиент не знает, успел ли партнёр создать заказ до обрыва ответа. Повтор может создать второй заказ. Метод <code>POST</code> не становится безопасным только потому, что cURL вернул ошибку. Безопасность повтора задаёт контракт конкретной операции.</p>\n<p>Для безопасного или идемпотентного чтения ограниченный повтор допустим, если API допускает его и общий бюджет не исчерпан. Для изменения состояния нужен постоянный ключ операции и документированная дедупликация у партнёра. Если ключ есть, после неизвестного результата сначала запросите статус по тому же ключу. Если ключа и проверки статуса нет, пометьте результат как неопределённый. Не запускайте второй create-вызов автоматически.</p>\n<pre><code><?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 без ключа и проверки статуса не повторяем.</code></pre>\n<p>Это учебная развилка. Она не заменяет описание API, лимиты повторов, дедупликацию и требования к очереди. Для денежных операций, заказов и других необратимых действий решение должен подтверждать владелец контракта. Даже идемпотентный метод не освобождает от ограничения числа повторов и общего deadline.</p>\n<h2>Порядок проверки</h2>\n<ol><li>Зафиксируйте метод, безопасный ID операции, путь, host, пороги, cURL error, HTTP-код и все накопительные времена.</li><li>Определите последнюю достигнутую стадию: соединение, первый байт или завершение тела.</li><li>Сверьте trace с успешным запросом того же маршрута и с логом партнёра, если он доступен.</li><li>Воспроизведите задержку на локальном учебном сервере и убедитесь, что обработчик различает первый байт и тело.</li><li>Проверьте одну внешнюю гипотезу: DNS/TLS, ожидание обработчика или размер и скорость ответа.</li><li>Для изменяющей операции проверьте ключ и запрос статуса до любого повтора.</li><li>Измените один предел или параметр контракта, повторите тот же тест и сравните trace.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Клиентский trace не показывает внутреннюю очередь партнёра, его SQL и работу прокси. Повторно используемое соединение может сделать connect коротким, хотя обработчик всё ещё отвечает поздно. Вызов из очереди имеет собственный deadline и собственные повторы. Их нужно учитывать отдельно. Большой ответ может завершить HTTP-перенос, а затем упасть на разборе или записи в базу. Это уже другой участок цепочки.</p>\n<p>Разбор готов, когда для одного тестового сценария видны cURL-код, HTTP-код, пороги и временная шкала; для задержки до первого байта и задержки тела есть отдельная проверка; выбранный предел связан с конкретной стадией; а изменяющая операция не повторяется при неизвестном результате без ключа и проверки статуса. Формат trace и маскирование секретов должны пройти проверку владельца интеграции. Production-результат из учебного примера не следует.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://curl.se/libcurl/c/libcurl-errors.html\" target=\"_blank\" rel=\"noopener noreferrer\">Документация libcurl: коды ошибок</a> — <code>CURLE_OPERATION_TIMEDOUT (28)</code> означает, что достигнут заданный предел операции, но сама ошибка не указывает стадию.</li><li><a href=\"https://curl.se/libcurl/c/CURLOPT_TIMEOUT.html\" target=\"_blank\" rel=\"noopener noreferrer\">Документация libcurl: CURLOPT_TIMEOUT</a> — общий предел переноса от начала до конца и его связь с таймаутом соединения.</li><li><a href=\"https://curl.se/libcurl/c/CURLOPT_CONNECTTIMEOUT.html\" target=\"_blank\" rel=\"noopener noreferrer\">Документация libcurl: CURLOPT_CONNECTTIMEOUT</a> — граница фазы соединения, включая разрешение имени и согласования протокола.</li><li><a href=\"https://curl.se/libcurl/c/curl_easy_getinfo.html\" target=\"_blank\" rel=\"noopener noreferrer\">Документация libcurl: curl_easy_getinfo</a> — определения временных показателей и порядок фаз от разрешения имени до общего времени.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html#name-idempotent-methods\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110, Idempotent Methods</a> — границы автоматического повтора и требование не повторять небезопасный метод без проверки результата или специального контракта.</li></ul>"
|
||
}
|