DarkRiDDeR11 мин

PHP cURL. Как дать веб-интеграции ограниченный бюджет времени

PHPcURLИнтеграции

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

В этой заметке разберём один узкий вопрос: как задать бюджеты ожидания для PHP cURL-запроса, записать результат и решить, когда его вообще можно повторить. Значения ниже учебные. Их нельзя переносить в другой API без размера ответа, ожидаемой нагрузки и договора с партнёром.

Сначала задаю время, которое можно отдать партнёру

Таймаут — это не число, которое берут из чужого примера. Сначала у сценария появляется предел. Допустим, страница готова ждать внешний остаток восемь секунд. Внутри этих восьми секунд соединению дадим две секунды. Оставшееся время занимает ожидание первого байта и получение тела. Если партнёр не уложился, текущая страница завершает свой путь, а не держит PHP бесконечно.

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

Шкала HTTP-запроса с отдельной границей соединения и общим пределом всего переноса.
Connect timeout ограничивает начальную фазу, общий timeout охватывает её вместе с ожиданием и телом ответа.

Разделяю три разных вопроса

У cURL есть несколько настроек, которые часто называют одним словом timeout. У них разная работа. Если смешать их в одну цифру, из лога с ошибкой 28 нельзя понять, был ли недоступен адрес, долго ли отвечал сервер или ответ передавался слишком медленно. Поэтому у каждого ограничения должна быть собственная причина появления.

ВопросНастройкаЧто ограничиваетЧто проверять при срабатывании
Успели ли открыть соединение?CURLOPT_CONNECTTIMEOUTDNS, TCP и переговоры протокола до установленного соединенияАдрес, DNS, сеть, TLS и короткий путь до партнёра
Успел ли закончиться весь запрос?CURLOPT_TIMEOUTВесь перенос от старта до конца, включая соединениеБюджет сценария, ожидание первого байта и размер тела
Не течёт ли ответ слишком медленно?CURLOPT_LOW_SPEED_LIMIT + CURLOPT_LOW_SPEED_TIMEСреднюю скорость ниже порога в течение заданного времениРазмер ответа, прокси-буферизацию и реальную скорость передачи
Можно ли попробовать ещё раз?Код приложенияБизнес-операцию, метод и оставшееся времяИдемпотентность и состояние операции у партнёра

Официальная документация libcurl прямо включает DNS и все переговоры до установленного соединения в connect phase. Она также говорит, что этот короткий предел находится внутри общего timeout. Поэтому connect timeout не складывают с total timeout: при двух и восьми секундах максимум всего вызова всё равно восемь, а не десять.

Минимальная настройка PHP cURL

Ниже функция не пытается решить бизнес-логику за приложение. Она возвращает тело, ошибку, HTTP-код и времена. Важная деталь: HTTP-код читаем отдельно от ошибки cURL. Сервер может ответить 500 быстро; это HTTP-ответ, а не таймаут транспорта. При сетевом обрыве HTTP-код обычно останется нулём.

<?php

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;
}

Пара low speed limit и low speed time нужна не как красивое третье число. Она подходит для ответа, который уже начался, но его средняя скорость долго остаётся ниже порога. В примере граница равна 100 байтам в секунду в течение трёх секунд. Для короткого JSON это может быть разумным сигналом зависания; для выгрузки большого файла такой порог нужно выбирать отдельно.

Не называю общий timeout read timeout

У простого вызова libcurl нет одной настройки, которая буквально означает «не ждать чтения N секунд». CURLOPT_TIMEOUT ограничивает весь перенос. Пара low speed ограничивает среднюю скорость передачи за период. Это близкий практический контроль для зависшего тела, но не тот же самый механизм. В тексте, логе и настройках лучше называть вещи своими именами — иначе следующая проверка окажется неверной.

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

Повторяю только чтение или известную операцию

Ошибка timeout не говорит, что партнёр ничего не сделал. Запрос мог дойти до сервера, операция могла выполниться, а ответ потеряться. Поэтому нельзя после любого POST просто вызвать ту же функцию ещё раз: заказ, платёж или заявка могут появиться дважды. HTTP различает методы по предполагаемому эффекту; в документе, действовавшем в 2018 году, GET, HEAD, PUT и DELETE имеют идемпотентную семантику, но конкретный API всё равно может иметь побочные действия вокруг них.

Для чтения можно оставить один контролируемый повтор, если ещё хватает времени на полезный ответ. Для изменения состояния нужен договор с партнёром: постоянный ключ операции, поиск состояния по нему или другой способ доказать, что первый вызов не был применён. Пока такого договора нет, результат таймаута следует считать неопределённым и передать на проверку, а не создавать второй объект.

<?php

function canRetryRead($method, $transportFailure, $attempt, $secondsLeft) {
    $readOnly = in_array($method, array("GET", "HEAD"), true);

    return $readOnly
        && $transportFailure
        && $attempt === 1
        && $secondsLeft >= 2;
}

// Для POST эта функция всегда вернёт false.
// Повтор POST требует отдельного контракта ключа операции у партнёра.

Порядок ввода в проект

  1. Назвать пользовательский сценарий и записать его внешний бюджет: сколько секунд можно ждать именно этому экрану или задаче.
  2. Поставить общий timeout меньше лимита PHP и короткий connect timeout внутри него; не суммировать их.
  3. Вернуть из клиента ошибку cURL, HTTP-код и несколько временных отметок, не записывая в журнал тело с персональными данными.
  4. На тестовом адресе проверить отдельно: недоступный хост, задержку до первого байта и медленное тело ответа.
  5. Для каждого метода зафиксировать правило повтора: GET и HEAD могут иметь один контролируемый повтор, изменение состояния — только после договора о ключе операции или проверке статуса.
  6. После первых журналов менять одну границу и повторять тот же сценарий, а не поднимать все таймауты одновременно.

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

Числа два, восемь, сто и три не являются нормативом. На выбор влияют число параллельных PHP-процессов, размер ожидаемого тела, повторное использование соединения, прокси и пользовательский сценарий. CURLINFO_TOTAL_TIME показывает длительность завершившегося переноса; он не заменяет отдельный замер очереди веб-сервера до входа в PHP. Если приложение идёт через несколько прокси, у каждого может быть свой предел ожидания, который тоже нужно знать.

Итог

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

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