{ "index": 342, "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+

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

" }