8 lines
15 KiB
JSON
8 lines
15 KiB
JSON
{
|
||
"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>Тезис простой: HTTP-вызову нужен общий бюджет, короткая граница установления соединения и отдельное правило для слишком медленного тела. Эти настройки отвечают на разные вопросы. Код должен сохранить результат cURL, HTTP-код и временные отметки. Только после этого можно решить, где искать причину и разрешён ли повтор.</p>\n+<h2>Что именно ограничивает таймаут</h2>\n+<p>Рассмотрим обычный синхронный вызов PHP cURL. Пусть экран может ждать внешний ответ восемь секунд. Из них две секунды отдадим на 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-запроса с границей соединения и общим бюджетом времени\"><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><?php\n+function requestPartnerPrice($url, $requestId) { $curl = curl_init($url); curl_setopt_array($curl, array(CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => array('Accept: application/json', 'X-Request-Id: ' . $requestId), CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 8, CURLOPT_LOW_SPEED_LIMIT => 100, CURLOPT_LOW_SPEED_TIME => 3)); $body = curl_exec($curl); $result = array('body' => $body, 'curl_errno' => curl_errno($curl), 'curl_error' => curl_error($curl), 'http_code' => curl_getinfo($curl, CURLINFO_HTTP_CODE), 'name_lookup' => curl_getinfo($curl, CURLINFO_NAMELOOKUP_TIME), 'connect' => curl_getinfo($curl, CURLINFO_CONNECT_TIME), 'start_transfer' => curl_getinfo($curl, CURLINFO_STARTTRANSFER_TIME), 'total' => curl_getinfo($curl, CURLINFO_TOTAL_TIME)); curl_close($curl); return $result; }</code></pre>\n+<p>В журнале достаточно безопасного идентификатора, метода, порогов, кода cURL, HTTP-кода и времён. Тело ответа и заголовки с токенами туда не попадают. <code>start_transfer</code> показывает момент первого байта, а <code>total</code> — длительность всего переноса. Эти значения относятся к cURL и не включают работу PHP до вызова и после разбора JSON.</p>\n+<h2>Симптомы ведут к разным проверкам</h2>\n+<table><thead><tr><th>Симптом</th><th>Причина</th><th>Проверка</th><th>Действие</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, не включать транспортный повтор</td></tr><tr><td>Таймаут после POST</td><td>Запрос мог выполниться, а ответ потерялся</td><td>Найти операцию по ключу или запросить её статус</td><td>Не отправлять POST снова без дедупликации</td></tr></tbody></table>\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><?php\n+function canRetryRead($method, $transportFailure, $attempt, $secondsLeft) { $readOnly = in_array($method, array('GET', 'HEAD'), true); return $readOnly && $transportFailure && $attempt === 1 && $secondsLeft >= 2; }\n+// Для POST нужен отдельный ключ операции и проверка статуса.</code></pre>\n+<h2>Порядок настройки и проверки</h2>\n+<ol><li>Назовите сценарий и внешний бюджет: сколько времени допустимо ждать экрану, очереди или фоновой задаче.</li><li>Поставьте общий <code>CURLOPT_TIMEOUT</code> ниже лимита PHP и шлюза. Внутри него задайте короткий <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. Клиентский trace не показывает внутреннюю очередь партнёра. Для этого нужны его логи и общий идентификатор операции.</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\">libcurl: CURLOPT_CONNECTTIMEOUT</a></li><li><a href=\"https://curl.se/libcurl/c/CURLOPT_TIMEOUT.html\" target=\"_blank\" rel=\"noopener\">libcurl: CURLOPT_TIMEOUT</a></li><li><a href=\"https://datatracker.ietf.org/doc/html/rfc9110#section-9.2.2\" target=\"_blank\" rel=\"noopener\">RFC 9110, раздел 9.2.2: идемпотентные методы</a></li></ul>"
|
||
}
|