DarkRiDDeR11 мин

PHP cURL. Почему connect, общий timeout и медленный ответ нельзя смешивать

PHPcURLHTTP

В журнале появляется curl_errno = 28, и его называют read timeout. После этого общий предел увеличивают с десяти до шестидесяти секунд. Ошибка остаётся, но PHP-процессы ждут в шесть раз дольше — цена неверного названия уже измеряется занятым пулом и медленным экраном.

Разберём механизм по частям: что cURL считает соединением, где находится общий предел, чем на самом деле служит low speed и почему error 28 не даёт разрешение повторить запрос. Это уровень PHP 2018: обычный синхронный вызов curl_exec, без поздних терминов и без обещания, что одна настройка исправит партнёра.

Connect phase начинается раньше TCP

В документации libcurl connect phase начинается с разрешения имени. В неё входят DNS, TCP и последующие протокольные переговоры, пока не появится установленное соединение с удалённой стороной. Для HTTPS сюда попадёт и TLS-рукопожатие. Поэтому медленный DNS легко проявится как превышение connect timeout, хотя сам TCP-пакет ещё не посылался.

Это полезная граница расследования. Если cURL не успел пройти connect phase, у приложения ещё нет HTTP-ответа партнёра. Проверяют URL, DNS, маршрут, сертификат и доступность конечного узла. Не стоит сразу искать медленный SQL на стороне API: до его обработчика запрос мог вообще не дойти.

Три условия остановки HTTP-вызова libcurl: connect phase, общий перенос и низкая скорость ответа.
Connect timeout охватывает начальную фазу, total timeout — весь перенос, low speed — среднюю скорость ниже выбранного порога.

Общий timeout накрывает соединение

Общий CURLOPT_TIMEOUT ограничивает весь перенос от старта до конца. Он не запускается после успешного соединения, а действует с самого начала. Документация libcurl приводит прямой пример: если connect timeout равен четырём секундам, а общий — двум, весь вызов остановится не позже двух. Из двух ограничений побеждает более ранняя граница.

Из этого следует простой контракт конфигурации: connect timeout всегда меньше или равен общему, а общий соответствует бюджету сценария. Если экран может ждать восемь секунд, ставить connect восемь и total восемь обычно не помогает отличить «не установили связь» от «партнёр долго отвечает». Короткая отдельная граница нужна для первой стадии, а не для сложения секунд.

СтадияЧто завершилосьПолезные поля после curl_execСледующая проверка
До соединенияНет подтверждённого соединения с узломname_lookup, connect, app_connect, HTTP-код 0DNS, маршрут, TLS, адрес партнёра
После соединения, до ответаСоединение есть, первый байт не пришёлconnect мал, start_transfer близок к totalОчередь или обработчик на стороне партнёра
После первого байтаОтвет начал идти, но не завершилсяstart_transfer мал, total близок к пределуРазмер ответа, прокси, скорость и формат выгрузки
HTTP-ошибкаСервер успел прислать статусHTTP-код не ноль, cURL может не иметь транспортной ошибкиКонтракт конкретного статуса, а не таймауты

Read timeout в этой конфигурации не отдельная ручка

В PHP cURL часто ищут настройку «таймаут чтения». Для простого вызова её лучше не выдумывать. CURLOPT_TIMEOUT — общий жёсткий потолок. CURLOPT_LOW_SPEED_LIMIT вместе с CURLOPT_LOW_SPEED_TIME говорит другое: средняя скорость переноса была ниже заданного числа байтов в секунду в течение выбранного времени, поэтому перенос остановлен.

Этот контроль полезен для ситуации «ответ начал идти, затем почти застыл». Но он может оборвать и легитимно медленную выгрузку, если порог выбран без знания размера и канала. Он не является проверкой того, что партнёр вообще не начал работу. На длинных отчётах лучше менять контракт — отдавать задачу асинхронно или получать результат частями, а не ставить слишком большой общий предел на страницу.

<?php

$curl = curl_init("https://partner.example/api/catalog");
curl_setopt_array($curl, array(
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CONNECTTIMEOUT => 2,
    CURLOPT_TIMEOUT => 8,
    CURLOPT_LOW_SPEED_LIMIT => 100,
    CURLOPT_LOW_SPEED_TIME => 3,
));

$body = curl_exec($curl);
$errno = curl_errno($curl);
$error = curl_error($curl);
curl_close($curl);

// errno 28 означает только достижение одного из условий таймаута.
// По одному errno нельзя определить стадию без сохранённых времён.

Сохраняю временную шкалу, а не одно название ошибки

После завершения переноса cURL даёт накопленные времена. Они не являются отдельными независимыми интервалами: каждое значение отсчитывается от начала запроса. Например, CONNECT_TIME — время от старта до подключения, а STARTTRANSFER_TIME — время от старта до первого полученного байта. Чтобы увидеть длительность участка, соседние накопленные отметки вычитают.

Для HTTPS пригодится CURLINFO_APPCONNECT_TIME. На обычном HTTP он может быть нулевым, и это не ошибка. В PHP старого проекта стоит проверять доступность нужных констант на установленной версии расширения, а не копировать набор полей из новой документации. Базовые NAMELOOKUP, CONNECT, STARTTRANSFER и TOTAL существовали задолго до 2018 года и дают достаточно материала для первой диагностики.

<?php

function curlTimes($curl) {
    return array(
        "name_lookup" => curl_getinfo($curl, CURLINFO_NAMELOOKUP_TIME),
        "connect" => curl_getinfo($curl, CURLINFO_CONNECT_TIME),
        "app_connect" => curl_getinfo($curl, CURLINFO_APPCONNECT_TIME),
        "start_transfer" => curl_getinfo($curl, CURLINFO_STARTTRANSFER_TIME),
        "total" => curl_getinfo($curl, CURLINFO_TOTAL_TIME),
    );
}

function difference($later, $earlier) {
    return max(0, $later - $earlier);
}

Если в логе connect = 0.18, start_transfer = 7.91 и total = 8.00, это не результат замера реального сервиса, а пример формы вывода: связь установилась быстро, а первый байт не пришёл до общего предела. Другой рисунок — start_transfer = 0.30 и total = 8.00 — указывает уже на передачу тела. Эти два случая нельзя лечить одной и той же настройкой.

Error 28 описывает итог, а не причину

Официальный список ошибок libcurl определяет CURLE_OPERATION_TIMEDOUT как достижение указанного условия таймаута. Он не записывает в код 28 отдельную метку «DNS», «сервер» или «тело ответа». Поэтому в одно событие диагностики кладут URL без секретной строки запроса, метод, пороги, error, HTTP-код, длину полученного тела и временную шкалу. Без сохранённых порогов даже хорошие времена нельзя сопоставить с решением клиента.

Не стоит логировать пароль, токен или полный JSON только ради расследования. В большинстве случаев достаточно пути API, корреляционного идентификатора, кода ошибки, HTTP-кода и чисел времени. Если нужен фрагмент тела для контракта ошибки, его согласуют отдельно и маскируют. Диагностика не должна превращать таймаут в утечку данных.

Повтор относится к операции, а не к error 28

Документ HTTP, действовавший в 2018 году, различает идемпотентные методы: повтор одинакового запроса должен иметь тот же предполагаемый эффект, что и один вызов. Он объясняет, почему после сбоя связи до чтения ответа клиент может повторить такую операцию. Актуальный RFC 9110 добавляет важную границу: клиент не должен автоматически повторять неидемпотентный запрос, если не знает, что семантика конкретной операции идемпотентна или что первый запрос точно не был применён.

Метод сам по себе не заменяет договор API. POST /orders без ключа операции не стоит повторять. POST с постоянным внешним идентификатором можно повторять только если партнёр документировал дедупликацию по этому идентификатору и приложение сохраняет один и тот же ключ на все попытки. После timeout с неизвестным состоянием безопасный путь часто состоит из проверки статуса операции, а не из второго создания.

Последовательность проверки

  1. Зафиксировать для одного маршрута общий бюджет и короткий connect timeout; указать их рядом с вызовом или в конфигурации.
  2. В тестовой среде отдельно вызвать несуществующий адрес, сервер с задержкой до первого байта и сервер с медленным телом.
  3. После каждого вызова записать cURL error, HTTP-код и накопленные времена NAMELOOKUP, CONNECT, STARTTRANSFER, TOTAL.
  4. Сопоставить рисунок времён с одной стадией, а не менять сразу DNS, timeout и повтор.
  5. Проверить метод и контракт операции до добавления повтора; для создания сущностей описать ключ операции или проверку статуса.
  6. Только затем менять конкретный предел и повторять тот же сценарий на тестовом адресе.

Ограничения разбора

Эти поля показывают путь со стороны клиента. Они не раскрывают внутренние очереди партнёра, его базу данных или работу промежуточного прокси. Повторно используемое соединение может сделать connect time маленьким, хотя новый запрос всё равно будет ждать обработчик. При параллельных вызовах или очереди в multi-интерфейсе смысл общего timeout тоже требует отдельной проверки по версии libcurl. Здесь разбирается обычный синхронный PHP-вызов.

Итог

Connect timeout, общий timeout и low speed отвечают на три разных наблюдения. Ошибка 28 говорит лишь, что одно из условий сработало. Когда рядом лежат пороги, HTTP-код и временная шкала, запрос перестаёт быть «зависшим вообще»: можно назвать стадию, проверить конкретную границу и не повторить небезопасную операцию.

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