{ "index": 342, "slug": "editorial-2018-07-practice-http-timeouts", "title": "PHP cURL: как ограничить время HTTP-запроса и не повторить операцию дважды", "excerpt": "Таймаут HTTP-запроса состоит из нескольких границ. Разбираем connect timeout, общий бюджет, медленное тело ответа, диагностику и безопасное правило повтора в PHP cURL.", "contentHtml": "
Симптом знакомый: страница ждёт цену от партнёрского API, PHP-процесс не освобождается, а пользователь видит пустой блок или ошибку шлюза. В журнале остаётся только «timeout». Команда увеличивает лимит с десяти до шестидесяти секунд, и проблема выглядит тише. На деле рабочий процесс занят в шесть раз дольше. При нагрузке это уменьшает пул доступных процессов и задерживает другие запросы. Если вызов меняет состояние партнёра, повтор после таймаута ещё и может создать второй заказ.
\nРазберём один синхронный вызов PHP cURL. Ему нужен общий бюджет, короткая граница установления соединения и отдельное правило для слишком медленного тела. Эти настройки отвечают на разные вопросы. Код должен сохранить результат cURL, HTTP-код и временные отметки. Только после этого можно решить, где искать причину и разрешён ли повтор.
\nПусть экран может ждать внешний ответ восемь секунд. Из них две секунды отдадим на DNS, TCP и TLS. Остаток оставим на ожидание первого байта и передачу тела. Это учебная модель. Её нельзя переносить в другой API без размера ответа, нагрузки и договора с партнёром.
\nCURLOPT_CONNECTTIMEOUT ограничивает фазу соединения: разрешение имени, протокольные переговоры и установление соединения. После соединения эта настройка сама по себе больше не ограничивает перенос. CURLOPT_TIMEOUT ограничивает всю операцию от начала до конца, поэтому два и восемь секунд не складываются. Если общий предел меньше connect timeout, сработает общий предел. Пара CURLOPT_LOW_SPEED_LIMIT и CURLOPT_LOW_SPEED_TIME нужна для другого случая: средняя скорость передачи остаётся ниже порога заданное время.
Настройки сами по себе не объясняют ошибку. После curl_exec нужно прочитать код ошибки, HTTP-статус и временные отметки до curl_close. HTTP-ответ 500, полученный за полсекунды, не является транспортным таймаутом. При сетевом сбое HTTP-код обычно равен нулю. Это разные ветки обработки.
<?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.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| 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 снова без дедупликации |
Код 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\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-ответа, а остаток времени — пересчитан перед новой попыткой.
\nCURLOPT_TIMEOUT с запасом до лимита вызывающего слоя и шлюза. Внутри него задайте короткий CURLOPT_CONNECTTIMEOUT.Значения 2, 8, 100 и 3 — учебные. На реальные числа влияют размер ответа, параллельность PHP-процессов, повторное использование соединения, прокси, DNS-кэш и договор с партнёром. Общий таймаут клиента не отменяет лимит балансировщика. Лимит шлюза может сработать раньше PHP. Журнал клиента не показывает внутреннюю очередь партнёра. Для этого нужны его логи и общий идентификатор операции.
\nLow-speed настройки не являются буквальным «таймаутом чтения N секунд». Они проверяют среднюю скорость ниже порога за период. Для маленького JSON и большого файла нужны разные пороги. Буферизация SAPI или прокси может изменить момент доставки первого байта, поэтому задержки проверяют на той же цепочке, где работает приложение.
\nНастройка готова, если для каждого тестового сценария журнал показывает одну понятную ветку: соединение, ожидание первого байта, передача тела или HTTP-статус. Общий предел не превышает лимит вызывающего слоя. Правило повтора записано отдельно для каждого метода. После искусственного таймаута GET не делает больше разрешённого числа попыток, а POST не повторяется, пока приложение не подтвердит ключ операции или статус. Это проверяемый результат, а не обещание, что внешний сервис больше никогда не задержится.
\nCURLE_OPERATION_TIMEDOUT (28).