Files

8 lines
17 KiB
JSON
Raw Permalink 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": 342,
"slug": "editorial-2018-07-practice-http-timeouts",
"title": "PHP cURL: как ограничить время HTTP-запроса и не повторить операцию дважды",
"excerpt": "Таймаут HTTP-запроса состоит из нескольких границ. Разбираем connect timeout, общий бюджет, медленное тело ответа, диагностику и безопасное правило повтора в PHP cURL.",
"contentHtml": "<p>Симптом знакомый: страница ждёт цену от партнёрского API, PHP-процесс не освобождается, а пользователь видит пустой блок или ошибку шлюза. В журнале остаётся только «timeout». Команда увеличивает лимит с десяти до шестидесяти секунд, и проблема выглядит тише. На деле рабочий процесс занят в шесть раз дольше. При нагрузке это уменьшает пул доступных процессов и задерживает другие запросы. Если вызов меняет состояние партнёра, повтор после таймаута ещё и может создать второй заказ.</p>\n<p>Разберём один синхронный вызов PHP cURL. Ему нужен общий бюджет, короткая граница установления соединения и отдельное правило для слишком медленного тела. Эти настройки отвечают на разные вопросы. Код должен сохранить результат cURL, HTTP-код и временные отметки. Только после этого можно решить, где искать причину и разрешён ли повтор.</p>\n<h2>Что именно ограничивает таймаут</h2>\n<p>Пусть экран может ждать внешний ответ восемь секунд. Из них две секунды отдадим на DNS, TCP и TLS. Остаток оставим на ожидание первого байта и передачу тела. Это учебная модель. Её нельзя переносить в другой API без размера ответа, нагрузки и договора с партнёром.</p>\n<p><code>CURLOPT_CONNECTTIMEOUT</code> ограничивает фазу соединения: разрешение имени, протокольные переговоры и установление соединения. После соединения эта настройка сама по себе больше не ограничивает перенос. <code>CURLOPT_TIMEOUT</code> ограничивает всю операцию от начала до конца, поэтому два и восемь секунд не складываются. Если общий предел меньше connect timeout, сработает общий предел. Пара <code>CURLOPT_LOW_SPEED_LIMIT</code> и <code>CURLOPT_LOW_SPEED_TIME</code> нужна для другого случая: средняя скорость передачи остаётся ниже порога заданное время.</p>\n<figure><img src=\"/assets/editorial/2018/http-timeout-budget-2018.svg\" alt=\"Шкала HTTP-запроса с фазой соединения внутри общего бюджета времени\" loading=\"lazy\"><figcaption>Общий бюджет охватывает весь перенос. Connect timeout ограничивает начальную фазу, а low-speed прерывает слишком медленную передачу по отдельному условию.</figcaption></figure>\n<h2>Минимальный клиент с измерениями</h2>\n<p>Настройки сами по себе не объясняют ошибку. После <code>curl_exec</code> нужно прочитать код ошибки, HTTP-статус и временные отметки до <code>curl_close</code>. HTTP-ответ 500, полученный за полсекунды, не является транспортным таймаутом. При сетевом сбое HTTP-код обычно равен нулю. Это разные ветки обработки.</p>\n<pre><code>&lt;?php\nfunction requestPartnerPrice($url, $requestId) {\n $curl = curl_init($url);\n curl_setopt_array($curl, array(\n CURLOPT_RETURNTRANSFER =&gt; true,\n CURLOPT_HTTPHEADER =&gt; array('Accept: application/json', 'X-Request-Id: ' . $requestId),\n CURLOPT_CONNECTTIMEOUT =&gt; 2,\n CURLOPT_TIMEOUT =&gt; 8,\n CURLOPT_LOW_SPEED_LIMIT =&gt; 100,\n CURLOPT_LOW_SPEED_TIME =&gt; 3,\n ));\n $body = curl_exec($curl);\n $result = array(\n 'method' =&gt; 'GET',\n 'body' =&gt; $body,\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 'start_transfer' =&gt; curl_getinfo($curl, CURLINFO_STARTTRANSFER_TIME),\n 'total' =&gt; curl_getinfo($curl, CURLINFO_TOTAL_TIME),\n );\n curl_close($curl);\n return $result;\n}</code></pre>\n<p>В журнале достаточно безопасного идентификатора, метода, порогов, кода cURL, HTTP-кода и времён. Тело ответа и заголовки с токенами туда не попадают. <code>start_transfer</code> показывает время до первого байта, а <code>total</code> — длительность всего переноса. Эти значения относятся к cURL и не включают работу PHP до вызова и после разбора JSON.</p>\n<h2>Симптомы ведут к разным проверкам</h2>\n<div class=\"table-scroll\"><table><caption>Матрица диагностики одного HTTP-вызова</caption><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>Сравнить name lookup и connect, проверить адрес и сертификат</td><td>Исправить сеть или узел; не увеличивать ожидание тела</td></tr><tr><td>Соединение быстрое, первый байт поздний</td><td>Партнёр долго ставит запрос в очередь или обрабатывает его</td><td>Сопоставить connect и start_transfer с его логом</td><td>Проверить очередь, уменьшить запрос или согласовать SLA</td></tr><tr><td>Первый байт ранний, total упирается в предел</td><td>Тело велико, канал медленный или прокси буферизует ответ</td><td>Сравнить размер тела, total и low-speed события</td><td>Уменьшить ответ или проверить прокси</td></tr><tr><td>HTTP 500 или 429 пришёл быстро</td><td>Сервер ответил статусом</td><td>Прочитать контракт статуса и тело ошибки</td><td>Обработать статус по API; для 429 проверить Retry-After</td></tr><tr><td>Таймаут после POST</td><td>Запрос мог выполниться, а ответ потерялся</td><td>Найти операцию по ключу или запросить её статус</td><td>Не отправлять POST снова без дедупликации</td></tr></tbody></table></div>\n<h2>Почему error 28 не объясняет всё</h2>\n<p>Код cURL 28 означает, что достигнуто одно из условий таймаута. Он не сообщает одной строкой, на какой стадии остановился вызов. Если <code>connect</code> почти равен двум секундам, а HTTP-код нулевой, проверку начинают до ответа партнёра. Если соединение установлено быстро, но <code>start_transfer</code> подходит к восьми секундам, ищут задержку обработки. Если первый байт пришёл рано, а <code>total</code> достиг общего предела, анализируют тело и канал.</p>\n<p>Не стоит складывать накопительные времена. <code>CURLINFO_NAMELOOKUP_TIME</code>, <code>CURLINFO_CONNECT_TIME</code> и <code>CURLINFO_STARTTRANSFER_TIME</code> отсчитываются от начала переноса. Длительность отдельной фазы получают сравнением отметок. Для HTTPS <code>CURLINFO_APPCONNECT_TIME</code> помогает увидеть завершение TLS. На обычном HTTP оно может быть нулевым.</p>\n<h2>Повтор — это решение о семантике</h2>\n<p>Таймаут не доказывает, что сервер ничего не сделал. Запрос мог дойти до обработчика, операция могла завершиться, а ответ мог потеряться на обратном пути. GET или HEAD можно повторить ограниченно, если в бюджете осталось время и повтор не создаёт побочного эффекта. Один контролируемый повтор не означает бесконечную очередь попыток.</p>\n<p>Для POST, который создаёт заказ, платёж или заявку, автоматический повтор без договора опасен. Нужен постоянный ключ операции, документированная дедупликация у партнёра или запрос статуса уже начатой операции. Если ни одного условия нет, результат помечают как неизвестный и передают на разбор. Лучше задержать одну операцию, чем создать две.</p>\n<pre><code>&lt;?php\nfunction canRetryRead($method, $transportFailure, $attemptsMade, $secondsLeft) {\n $readOnly = in_array($method, array('GET', 'HEAD'), true);\n return $readOnly &amp;&amp; $transportFailure &amp;&amp; $attemptsMade === 1 &amp;&amp; $secondsLeft &gt;= 2;\n}\n// attemptsMade === 1 означает: одна попытка уже завершилась сбоем.\n// Для POST нужен отдельный ключ операции и проверка статуса.</code></pre>\n<p>В этом фрагменте счётчик начинается с единицы после первой попытки, поэтому функция разрешает не более одного повтора чтения. Условия остаются частью контракта вызывающего кода: транспортная ошибка должна быть отделена от HTTP-ответа, а остаток времени — пересчитан перед новой попыткой.</p>\n<h2>Порядок настройки и проверки</h2>\n<ol><li>Назовите сценарий и внешний бюджет: сколько времени допустимо ждать экрану, очереди или фоновой задаче.</li><li>Поставьте общий <code>CURLOPT_TIMEOUT</code> с запасом до лимита вызывающего слоя и шлюза. Внутри него задайте короткий <code>CURLOPT_CONNECTTIMEOUT</code>.</li><li>Сохраните безопасный ID, метод, cURL error, HTTP-код, пороги и временные отметки. Не записывайте секреты и полное тело.</li><li>Проверьте на тестовом адресе недоступный хост, задержку до первого байта и медленную передачу тела. Учебный стенд не доказывает поведение production-прокси.</li><li>Для GET и HEAD зафиксируйте число повторов и оставшееся время. Для POST сначала проверьте ключ операции или статус, а не отправляйте тот же запрос снова.</li><li>Меняйте одну границу за раз. После изменения повторите тот же сценарий и сравните ту же временную отметку.</li></ol>\n<h2>Ограничения примера</h2>\n<p>Значения 2, 8, 100 и 3 — учебные. На реальные числа влияют размер ответа, параллельность PHP-процессов, повторное использование соединения, прокси, DNS-кэш и договор с партнёром. Общий таймаут клиента не отменяет лимит балансировщика. Лимит шлюза может сработать раньше PHP. Журнал клиента не показывает внутреннюю очередь партнёра. Для этого нужны его логи и общий идентификатор операции.</p>\n<p>Low-speed настройки не являются буквальным «таймаутом чтения N секунд». Они проверяют среднюю скорость ниже порога за период. Для маленького JSON и большого файла нужны разные пороги. Буферизация SAPI или прокси может изменить момент доставки первого байта, поэтому задержки проверяют на той же цепочке, где работает приложение.</p>\n<h2>Критерий готовности</h2>\n<p>Настройка готова, если для каждого тестового сценария журнал показывает одну понятную ветку: соединение, ожидание первого байта, передача тела или HTTP-статус. Общий предел не превышает лимит вызывающего слоя. Правило повтора записано отдельно для каждого метода. После искусственного таймаута GET не делает больше разрешённого числа попыток, а POST не повторяется, пока приложение не подтвердит ключ операции или статус. Это проверяемый результат, а не обещание, что внешний сервис больше никогда не задержится.</p>\n<h2>Проверяемые источники</h2><ul><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/CURLOPT_TIMEOUT.html\" target=\"_blank\" rel=\"noopener noreferrer\">libcurl: CURLOPT_TIMEOUT</a> — общий предел всей операции.</li><li><a href=\"https://curl.se/libcurl/c/CURLOPT_LOW_SPEED_LIMIT.html\" target=\"_blank\" rel=\"noopener noreferrer\">libcurl: CURLOPT_LOW_SPEED_LIMIT</a> и <a href=\"https://curl.se/libcurl/c/CURLOPT_LOW_SPEED_TIME.html\" target=\"_blank\" rel=\"noopener noreferrer\">CURLOPT_LOW_SPEED_TIME</a> — средняя скорость и период проверки.</li><li><a href=\"https://www.php.net/manual/en/function.curl-getinfo.php\" target=\"_blank\" rel=\"noopener noreferrer\">PHP manual: curl_getinfo</a> — доступные временные поля и HTTP-код результата.</li><li><a href=\"https://datatracker.ietf.org/doc/html/rfc7231#section-4.2.2\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 7231, раздел 4.2.2</a> и <a href=\"https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110, раздел 9.2.2</a> — историческая и действующая формулировки об идемпотентности и повторе запросов.</li><li><a href=\"https://curl.se/libcurl/c/libcurl-errors.html\" target=\"_blank\" rel=\"noopener noreferrer\">libcurl error codes</a> — значение <code>CURLE_OPERATION_TIMEDOUT</code> (28).</li></ul>"
}