8 lines
18 KiB
JSON
8 lines
18 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>STARTTRANSFER_TIME</code> отсчитывается от начала вызова и означает момент получения первого байта, а не момент разбора JSON приложением.</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$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);</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><?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));</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><?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}</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>"
|
||
}
|