diff --git a/editorial/agent-rewrites/342.json b/editorial/agent-rewrites/342.json index 60a52a8..402d5f5 100644 --- a/editorial/agent-rewrites/342.json +++ b/editorial/agent-rewrites/342.json @@ -3,5 +3,5 @@ "slug": "editorial-2018-07-practice-http-timeouts", "title": "PHP cURL: как ограничить время HTTP-запроса и не повторить операцию дважды", "excerpt": "Таймаут HTTP-запроса состоит из нескольких границ. Разбираем connect timeout, общий бюджет, медленное тело ответа, диагностику и безопасное правило повтора в PHP cURL.", - "contentHtml": "

Симптом знакомый: страница ждёт цену от партнёрского API, PHP-процесс не освобождается, а пользователь видит пустой блок или ошибку шлюза. В журнале остаётся только «timeout». Команда увеличивает лимит с десяти до шестидесяти секунд, и проблема выглядит тише. На деле рабочий процесс занят в шесть раз дольше. При нагрузке это уменьшает пул доступных процессов и задерживает другие запросы. Если вызов меняет состояние партнёра, повтор после таймаута ещё и может создать второй заказ.

\n+

Тезис простой: HTTP-вызову нужен общий бюджет, короткая граница установления соединения и отдельное правило для слишком медленного тела. Эти настройки отвечают на разные вопросы. Код должен сохранить результат cURL, HTTP-код и временные отметки. Только после этого можно решить, где искать причину и разрешён ли повтор.

\n+

Что именно ограничивает таймаут

\n+

Рассмотрим обычный синхронный вызов PHP cURL. Пусть экран может ждать внешний ответ восемь секунд. Из них две секунды отдадим на DNS, TCP и TLS. Остаток оставим на ожидание первого байта и передачу тела. Это учебная модель. Её нельзя переносить в другой API без размера ответа, нагрузки и договора с партнёром.

\n+

CURLOPT_CONNECTTIMEOUT ограничивает начальную фазу соединения. Она включает разрешение имени и переговоры до установленного соединения. CURLOPT_TIMEOUT ограничивает весь перенос от начала до конца. Поэтому два и восемь секунд не складываются. Если общий предел меньше connect timeout, сработает общий предел. Пара CURLOPT_LOW_SPEED_LIMIT и CURLOPT_LOW_SPEED_TIME нужна для другого случая: ответ уже идёт, но средняя скорость остаётся ниже порога.

\n+
\"Шкала
Общий бюджет охватывает весь перенос. Connect timeout ограничивает начальную фазу, а low-speed проверяет слишком медленную передачу.
\n+

Минимальный клиент с измерениями

\n+

Настройки сами по себе не объясняют ошибку. После curl_exec нужно прочитать код ошибки, HTTP-статус и временные отметки до curl_close. HTTP-ответ 500, полученный за полсекунды, не является транспортным таймаутом. При сетевом сбое HTTP-код обычно равен нулю. Это разные ветки обработки.

\n+
<?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; }
\n+

В журнале достаточно безопасного идентификатора, метода, порогов, кода cURL, HTTP-кода и времён. Тело ответа и заголовки с токенами туда не попадают. start_transfer показывает момент первого байта, а total — длительность всего переноса. Эти значения относятся к cURL и не включают работу PHP до вызова и после разбора JSON.

\n+

Симптомы ведут к разным проверкам

\n+
СимптомПричинаПроверкаДействие
HTTP-код 0, connect близок к лимитуDNS, маршрут, TCP или TLS не завершилисьСравнить name lookup и connect, проверить адрес и сертификатИсправить сеть или узел; не увеличивать ожидание тела
Соединение быстрое, первый байт позднийПартнёр долго ставит запрос в очередь или обрабатывает егоСопоставить connect и start_transfer с его логомПроверить очередь, уменьшить запрос или согласовать SLA
Первый байт ранний, total упирается в пределТело велико, канал медленный или прокси буферизует ответСравнить размер тела, total и low-speed событияУменьшить ответ или проверить прокси
HTTP 500 или 429 пришёл быстроСервер ответил статусомПрочитать контракт статуса и тело ошибкиОбработать статус по API, не включать транспортный повтор
Таймаут после POSTЗапрос мог выполниться, а ответ потерялсяНайти операцию по ключу или запросить её статусНе отправлять POST снова без дедупликации
\n+

Почему error 28 не объясняет всё

\n+

Код cURL 28 означает, что достигнуто одно из условий таймаута. Он не сообщает одной строкой, на какой стадии остановился вызов. Если connect почти равен двум секундам, а HTTP-код нулевой, проблема находится до ответа партнёра. Если соединение установлено быстро, но start_transfer подходит к восьми секундам, ищут задержку обработки. Если первый байт пришёл рано, а total достиг общего предела, анализируют тело и канал.

\n+

Не стоит складывать накопительные времена. CURLINFO_NAMELOOKUP_TIME, CURLINFO_CONNECT_TIME и CURLINFO_STARTTRANSFER_TIME отсчитываются от начала переноса. Длительность отдельной фазы получают сравнением отметок. Для HTTPS CURLINFO_APPCONNECT_TIME помогает увидеть завершение TLS. На обычном HTTP оно может быть нулевым.

\n+

Повтор — это решение о семантике

\n+

Таймаут не доказывает, что сервер ничего не сделал. Запрос мог дойти до обработчика, операция могла завершиться, а ответ мог потеряться на обратном пути. GET или HEAD можно повторить ограниченно, если в бюджете осталось время и повтор не создаёт побочного эффекта. Один контролируемый повтор не означает бесконечную очередь попыток.

\n+

Для POST, который создаёт заказ, платёж или заявку, автоматический повтор без договора опасен. Нужен постоянный ключ операции, документированная дедупликация у партнёра или запрос статуса уже начатой операции. Если ни одного условия нет, результат помечают как неизвестный и передают на разбор. Лучше задержать одну операцию, чем создать две.

\n+
<?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 нужен отдельный ключ операции и проверка статуса.
\n+

Порядок настройки и проверки

\n+
  1. Назовите сценарий и внешний бюджет: сколько времени допустимо ждать экрану, очереди или фоновой задаче.
  2. Поставьте общий CURLOPT_TIMEOUT ниже лимита PHP и шлюза. Внутри него задайте короткий CURLOPT_CONNECTTIMEOUT.
  3. Сохраните безопасный ID, метод, cURL error, HTTP-код, пороги и временные отметки. Не записывайте секреты и полное тело.
  4. Проверьте на тестовом адресе недоступный хост, задержку до первого байта и медленную передачу тела. Учебный стенд не доказывает поведение production-прокси.
  5. Для GET и HEAD зафиксируйте число повторов и оставшееся время. Для POST сначала проверьте ключ операции или статус, а не отправляйте тот же запрос снова.
  6. Меняйте одну границу за раз. После изменения повторите тот же сценарий и сравните ту же временную отметку.
\n+

Ограничения примера

\n+

Значения 2, 8, 100 и 3 — учебные. На реальные числа влияют размер ответа, параллельность PHP-процессов, повторное использование соединения, прокси, DNS-кэш и договор с партнёром. Общий таймаут клиента не отменяет лимит балансировщика. Лимит шлюза может сработать раньше PHP. Клиентский trace не показывает внутреннюю очередь партнёра. Для этого нужны его логи и общий идентификатор операции.

\n+

Low-speed настройки не являются буквальным «таймаутом чтения N секунд». Они проверяют среднюю скорость ниже порога за период. Для маленького JSON и большого файла нужны разные пороги. Буферизация SAPI или прокси может изменить момент доставки первого байта, поэтому задержки проверяют на той же цепочке, где работает приложение.

\n+

Критерий готовности

\n+

Настройка готова, если для каждого тестового сценария журнал показывает одну понятную ветку: соединение, ожидание первого байта, передача тела или HTTP-статус. Общий предел не превышает лимит вызывающего слоя. Правило повтора записано отдельно для каждого метода. После искусственного таймаута GET не делает больше разрешённого числа попыток, а POST не повторяется, пока приложение не подтвердит ключ операции или статус. Это проверяемый результат, а не обещание, что внешний сервис больше никогда не задержится.

\n+

Проверяемые источники

" + "contentHtml": "

Симптом знакомый: страница ждёт цену от партнёрского API, PHP-процесс не освобождается, а пользователь видит пустой блок или ошибку шлюза. В журнале остаётся только «timeout». Команда увеличивает лимит с десяти до шестидесяти секунд, и проблема выглядит тише. На деле рабочий процесс занят в шесть раз дольше. При нагрузке это уменьшает пул доступных процессов и задерживает другие запросы. Если вызов меняет состояние партнёра, повтор после таймаута ещё и может создать второй заказ.

\n

Разберём один синхронный вызов PHP cURL. Ему нужен общий бюджет, короткая граница установления соединения и отдельное правило для слишком медленного тела. Эти настройки отвечают на разные вопросы. Код должен сохранить результат cURL, HTTP-код и временные отметки. Только после этого можно решить, где искать причину и разрешён ли повтор.

\n

Что именно ограничивает таймаут

\n

Пусть экран может ждать внешний ответ восемь секунд. Из них две секунды отдадим на DNS, TCP и TLS. Остаток оставим на ожидание первого байта и передачу тела. Это учебная модель. Её нельзя переносить в другой API без размера ответа, нагрузки и договора с партнёром.

\n

CURLOPT_CONNECTTIMEOUT ограничивает фазу соединения: разрешение имени, протокольные переговоры и установление соединения. После соединения эта настройка сама по себе больше не ограничивает перенос. CURLOPT_TIMEOUT ограничивает всю операцию от начала до конца, поэтому два и восемь секунд не складываются. Если общий предел меньше connect timeout, сработает общий предел. Пара CURLOPT_LOW_SPEED_LIMIT и CURLOPT_LOW_SPEED_TIME нужна для другого случая: средняя скорость передачи остаётся ниже порога заданное время.

\n
\"Шкала
Общий бюджет охватывает весь перенос. Connect timeout ограничивает начальную фазу, а low-speed прерывает слишком медленную передачу по отдельному условию.
\n

Минимальный клиент с измерениями

\n

Настройки сами по себе не объясняют ошибку. После curl_exec нужно прочитать код ошибки, HTTP-статус и временные отметки до curl_close. HTTP-ответ 500, полученный за полсекунды, не является транспортным таймаутом. При сетевом сбое HTTP-код обычно равен нулю. Это разные ветки обработки.

\n
<?php\nfunction requestPartnerPrice($url, $requestId) {\n    $curl = curl_init($url);\n    curl_setopt_array($curl, array(\n        CURLOPT_RETURNTRANSFER => true,\n        CURLOPT_HTTPHEADER => array('Accept: application/json', 'X-Request-Id: ' . $requestId),\n        CURLOPT_CONNECTTIMEOUT => 2,\n        CURLOPT_TIMEOUT => 8,\n        CURLOPT_LOW_SPEED_LIMIT => 100,\n        CURLOPT_LOW_SPEED_TIME => 3,\n    ));\n    $body = curl_exec($curl);\n    $result = array(\n        'method' => 'GET',\n        'body' => $body,\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        'start_transfer' => curl_getinfo($curl, CURLINFO_STARTTRANSFER_TIME),\n        'total' => curl_getinfo($curl, CURLINFO_TOTAL_TIME),\n    );\n    curl_close($curl);\n    return $result;\n}
\n

В журнале достаточно безопасного идентификатора, метода, порогов, кода cURL, HTTP-кода и времён. Тело ответа и заголовки с токенами туда не попадают. start_transfer показывает время до первого байта, а total — длительность всего переноса. Эти значения относятся к cURL и не включают работу PHP до вызова и после разбора JSON.

\n

Симптомы ведут к разным проверкам

\n
Матрица диагностики одного HTTP-вызова
СимптомПричинаПроверкаДействие
HTTP-код 0, connect близок к лимитуDNS, маршрут, TCP или TLS не завершилисьСравнить name lookup и connect, проверить адрес и сертификатИсправить сеть или узел; не увеличивать ожидание тела
Соединение быстрое, первый байт позднийПартнёр долго ставит запрос в очередь или обрабатывает егоСопоставить connect и start_transfer с его логомПроверить очередь, уменьшить запрос или согласовать SLA
Первый байт ранний, total упирается в пределТело велико, канал медленный или прокси буферизует ответСравнить размер тела, total и low-speed событияУменьшить ответ или проверить прокси
HTTP 500 или 429 пришёл быстроСервер ответил статусомПрочитать контракт статуса и тело ошибкиОбработать статус по API; для 429 проверить Retry-After
Таймаут после POSTЗапрос мог выполниться, а ответ потерялсяНайти операцию по ключу или запросить её статусНе отправлять POST снова без дедупликации
\n

Почему error 28 не объясняет всё

\n

Код cURL 28 означает, что достигнуто одно из условий таймаута. Он не сообщает одной строкой, на какой стадии остановился вызов. Если connect почти равен двум секундам, а HTTP-код нулевой, проверку начинают до ответа партнёра. Если соединение установлено быстро, но start_transfer подходит к восьми секундам, ищут задержку обработки. Если первый байт пришёл рано, а total достиг общего предела, анализируют тело и канал.

\n

Не стоит складывать накопительные времена. CURLINFO_NAMELOOKUP_TIME, CURLINFO_CONNECT_TIME и CURLINFO_STARTTRANSFER_TIME отсчитываются от начала переноса. Длительность отдельной фазы получают сравнением отметок. Для HTTPS CURLINFO_APPCONNECT_TIME помогает увидеть завершение TLS. На обычном HTTP оно может быть нулевым.

\n

Повтор — это решение о семантике

\n

Таймаут не доказывает, что сервер ничего не сделал. Запрос мог дойти до обработчика, операция могла завершиться, а ответ мог потеряться на обратном пути. GET или HEAD можно повторить ограниченно, если в бюджете осталось время и повтор не создаёт побочного эффекта. Один контролируемый повтор не означает бесконечную очередь попыток.

\n

Для POST, который создаёт заказ, платёж или заявку, автоматический повтор без договора опасен. Нужен постоянный ключ операции, документированная дедупликация у партнёра или запрос статуса уже начатой операции. Если ни одного условия нет, результат помечают как неизвестный и передают на разбор. Лучше задержать одну операцию, чем создать две.

\n
<?php\nfunction canRetryRead($method, $transportFailure, $attemptsMade, $secondsLeft) {\n    $readOnly = in_array($method, array('GET', 'HEAD'), true);\n    return $readOnly && $transportFailure && $attemptsMade === 1 && $secondsLeft >= 2;\n}\n// attemptsMade === 1 означает: одна попытка уже завершилась сбоем.\n// Для POST нужен отдельный ключ операции и проверка статуса.
\n

В этом фрагменте счётчик начинается с единицы после первой попытки, поэтому функция разрешает не более одного повтора чтения. Условия остаются частью контракта вызывающего кода: транспортная ошибка должна быть отделена от HTTP-ответа, а остаток времени — пересчитан перед новой попыткой.

\n

Порядок настройки и проверки

\n
  1. Назовите сценарий и внешний бюджет: сколько времени допустимо ждать экрану, очереди или фоновой задаче.
  2. Поставьте общий CURLOPT_TIMEOUT с запасом до лимита вызывающего слоя и шлюза. Внутри него задайте короткий CURLOPT_CONNECTTIMEOUT.
  3. Сохраните безопасный ID, метод, cURL error, HTTP-код, пороги и временные отметки. Не записывайте секреты и полное тело.
  4. Проверьте на тестовом адресе недоступный хост, задержку до первого байта и медленную передачу тела. Учебный стенд не доказывает поведение production-прокси.
  5. Для GET и HEAD зафиксируйте число повторов и оставшееся время. Для POST сначала проверьте ключ операции или статус, а не отправляйте тот же запрос снова.
  6. Меняйте одну границу за раз. После изменения повторите тот же сценарий и сравните ту же временную отметку.
\n

Ограничения примера

\n

Значения 2, 8, 100 и 3 — учебные. На реальные числа влияют размер ответа, параллельность PHP-процессов, повторное использование соединения, прокси, DNS-кэш и договор с партнёром. Общий таймаут клиента не отменяет лимит балансировщика. Лимит шлюза может сработать раньше PHP. Журнал клиента не показывает внутреннюю очередь партнёра. Для этого нужны его логи и общий идентификатор операции.

\n

Low-speed настройки не являются буквальным «таймаутом чтения N секунд». Они проверяют среднюю скорость ниже порога за период. Для маленького JSON и большого файла нужны разные пороги. Буферизация SAPI или прокси может изменить момент доставки первого байта, поэтому задержки проверяют на той же цепочке, где работает приложение.

\n

Критерий готовности

\n

Настройка готова, если для каждого тестового сценария журнал показывает одну понятную ветку: соединение, ожидание первого байта, передача тела или HTTP-статус. Общий предел не превышает лимит вызывающего слоя. Правило повтора записано отдельно для каждого метода. После искусственного таймаута GET не делает больше разрешённого числа попыток, а POST не повторяется, пока приложение не подтвердит ключ операции или статус. Это проверяемый результат, а не обещание, что внешний сервис больше никогда не задержится.

\n

Проверяемые источники

" }