{ "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+Рассмотрим обычный синхронный вызов PHP cURL. Пусть экран может ждать внешний ответ восемь секунд. Из них две секунды отдадим на DNS, TCP и TLS. Остаток оставим на ожидание первого байта и передачу тела. Это учебная модель. Её нельзя переносить в другой API без размера ответа, нагрузки и договора с партнёром.
\n+CURLOPT_CONNECTTIMEOUT ограничивает начальную фазу соединения. Она включает разрешение имени и переговоры до установленного соединения. CURLOPT_TIMEOUT ограничивает весь перенос от начала до конца. Поэтому два и восемь секунд не складываются. Если общий предел меньше connect timeout, сработает общий предел. Пара CURLOPT_LOW_SPEED_LIMIT и CURLOPT_LOW_SPEED_TIME нужна для другого случая: ответ уже идёт, но средняя скорость остаётся ниже порога.
Настройки сами по себе не объясняют ошибку. После curl_exec нужно прочитать код ошибки, HTTP-статус и временные отметки до curl_close. HTTP-ответ 500, полученный за полсекунды, не является транспортным таймаутом. При сетевом сбое HTTP-код обычно равен нулю. Это разные ветки обработки.
<?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.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| HTTP-код 0, connect близок к лимиту | DNS, маршрут, TCP или TLS не завершились | Сравнить name lookup и connect, проверить адрес и сертификат | Исправить сеть или узел; не увеличивать ожидание тела |
| Соединение быстрое, первый байт поздний | Партнёр долго ставит запрос в очередь или обрабатывает его | Сопоставить connect и start_transfer с его логом | Проверить очередь, уменьшить запрос или согласовать SLA |
| Первый байт ранний, total упирается в предел | Тело велико, канал медленный или прокси буферизует ответ | Сравнить размер тела, total и low-speed события | Уменьшить ответ или проверить прокси |
| HTTP 500 или 429 пришёл быстро | Сервер ответил статусом | Прочитать контракт статуса и тело ошибки | Обработать статус по API, не включать транспортный повтор |
| Таймаут после POST | Запрос мог выполниться, а ответ потерялся | Найти операцию по ключу или запросить её статус | Не отправлять POST снова без дедупликации |
Код cURL 28 означает, что достигнуто одно из условий таймаута. Он не сообщает одной строкой, на какой стадии остановился вызов. Если connect почти равен двум секундам, а HTTP-код нулевой, проблема находится до ответа партнёра. Если соединение установлено быстро, но start_transfer подходит к восьми секундам, ищут задержку обработки. Если первый байт пришёл рано, а total достиг общего предела, анализируют тело и канал.
Не стоит складывать накопительные времена. CURLINFO_NAMELOOKUP_TIME, CURLINFO_CONNECT_TIME и CURLINFO_STARTTRANSFER_TIME отсчитываются от начала переноса. Длительность отдельной фазы получают сравнением отметок. Для HTTPS CURLINFO_APPCONNECT_TIME помогает увидеть завершение TLS. На обычном HTTP оно может быть нулевым.
Таймаут не доказывает, что сервер ничего не сделал. Запрос мог дойти до обработчика, операция могла завершиться, а ответ мог потеряться на обратном пути. 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+CURLOPT_TIMEOUT ниже лимита PHP и шлюза. Внутри него задайте короткий CURLOPT_CONNECTTIMEOUT.Значения 2, 8, 100 и 3 — учебные. На реальные числа влияют размер ответа, параллельность PHP-процессов, повторное использование соединения, прокси, DNS-кэш и договор с партнёром. Общий таймаут клиента не отменяет лимит балансировщика. Лимит шлюза может сработать раньше PHP. Клиентский trace не показывает внутреннюю очередь партнёра. Для этого нужны его логи и общий идентификатор операции.
\n+Low-speed настройки не являются буквальным «таймаутом чтения N секунд». Они проверяют среднюю скорость ниже порога за период. Для маленького JSON и большого файла нужны разные пороги. Буферизация SAPI или прокси может изменить момент доставки первого байта, поэтому задержки проверяют на той же цепочке, где работает приложение.
\n+Настройка готова, если для каждого тестового сценария журнал показывает одну понятную ветку: соединение, ожидание первого байта, передача тела или HTTP-статус. Общий предел не превышает лимит вызывающего слоя. Правило повтора записано отдельно для каждого метода. После искусственного таймаута GET не делает больше разрешённого числа попыток, а POST не повторяется, пока приложение не подтвердит ключ операции или статус. Это проверяемый результат, а не обещание, что внешний сервис больше никогда не задержится.
\n+