Files
progcode/editorial/agent-rewrites/340.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
18 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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>STARTTRANSFER_TIME</code> отсчитывается от начала вызова и означает момент получения первого байта, а не момент разбора JSON приложением.</p>\n<pre><code>&lt;?php\n\nfunction traceCurl($curl, $operationId, array $limits) {\n return array(\n 'operation_id' =&gt; $operationId,\n 'curl_errno' =&gt; curl_errno($curl),\n 'curl_error' =&gt; curl_error($curl),\n 'http_code' =&gt; curl_getinfo($curl, CURLINFO_HTTP_CODE),\n 'name_lookup' =&gt; curl_getinfo($curl, CURLINFO_NAMELOOKUP_TIME),\n 'connect' =&gt; curl_getinfo($curl, CURLINFO_CONNECT_TIME),\n 'app_connect' =&gt; curl_getinfo($curl, CURLINFO_APPCONNECT_TIME),\n 'start_transfer' =&gt; curl_getinfo($curl, CURLINFO_STARTTRANSFER_TIME),\n 'total' =&gt; curl_getinfo($curl, CURLINFO_TOTAL_TIME),\n 'connect_limit' =&gt; $limits['connect'],\n 'total_limit' =&gt; $limits['total'],\n );\n}\n\n$limits = array('connect' =&gt; 2, 'total' =&gt; 8);\n$curl = curl_init($partnerUrl);\ncurl_setopt_array($curl, array(\n CURLOPT_RETURNTRANSFER =&gt; true,\n CURLOPT_CONNECTTIMEOUT =&gt; $limits['connect'],\n CURLOPT_TIMEOUT =&gt; $limits['total'],\n));\n\n$body = curl_exec($curl);\n$trace = traceCurl($curl, $operationId, $limits);\ncurl_close($curl);</code></pre>\n<p>Код показывает формат учебного trace, а не готовую библиотеку логирования. В production отдельно проверьте версию PHP и libcurl, типы полей и маскирование. После <code>curl_exec</code> сохраните trace даже при ошибке. Иначе обработчик оставит только текст исключения и потеряет стадию сбоя.</p>\n<h2>Разделяем задержки на тестовом стенде</h2>\n<p>Проверяйте обработчик на локальном сервере. Не ждите, пока настоящий партнёр случайно замедлится. Учебный маршрут ниже создаёт две задержки. Первый маршрут задерживает первый байт. Второй отправляет начало тела и задерживает хвост. Это не модель интернета и не результат production-наблюдений. Цель примера — увидеть разницу между <code>STARTTRANSFER_TIME</code> и <code>TOTAL_TIME</code> на своём клиенте.</p>\n<pre><code>&lt;?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' =&gt; 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' =&gt; true));</code></pre>\n<p>Запустите учебный сервер командой <code>php -S 127.0.0.1:8080 router.php</code>. Вызов <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>DNS, маршрут, TCP или TLS не завершились</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>&lt;?php\n\nfunction actionAfterTimeout($method, $hasOperationKey, $canCheckStatus) {\n if ($method === 'GET' || $method === 'HEAD') {\n return 'one_limited_retry';\n }\n\n if ($hasOperationKey &amp;&amp; $canCheckStatus) {\n return 'check_operation_status';\n }\n\n return 'mark_result_unknown';\n}</code></pre>\n<p>Это учебная развилка. Она не заменяет описание API, лимиты повторов, дедупликацию и требования к очереди. Для денежных операций, заказов и других необратимых действий решение должен подтверждать владелец контракта.</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/CURLOPT_TIMEOUT.html\" target=\"_blank\" rel=\"noopener noreferrer\">Документация libcurl: CURLOPT_TIMEOUT</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> — границы автоматического повтора HTTP-операций.</li></ul>"
}