DarkRiDDeR12 мин

PHP cURL. Как разобрать зависшую интеграцию по стадиям запроса

PHPcURLПрактика

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

Ниже учебный полевой маршрут для PHP 2018: как добавить к одному cURL-вызову наблюдаемые стадии, воспроизвести три типа задержки на локальном стенде и принять решение без догадки. Числа и строки лога здесь являются форматом примера, а не заявлением о результатах чужого сервиса.

Сначала фиксирую то, что клиент действительно видел

В журнал нельзя писать только фразу «curl timeout». Нужны как минимум метод, безопасный идентификатор операции, cURL error, HTTP-код, пороги клиента и временные отметки. HTTP-код ноль означает, что cURL не получил HTTP-статус от целевого сервера; ненулевой код отделяет транспортную проблему от ответа, который успел сформироваться. Полный URL с токеном, тело запроса и ответ партнёра в общий лог не кладём.

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

Дерево разбора HTTP-вызова: нет HTTP-кода, поздний первый байт или медленное тело ответа приводят к разным проверкам.
Временные отметки ограничивают место поиска. Повтор не выполняется автоматически для операции с неизвестным состоянием.

Собираю одну строку диагностики после curl_exec

Код ниже рассчитан на обычный PHP cURL. Сначала завершается curl_exec, затем до curl_close читаются error и сведения о переносе. Время start_transfer означает момент первого полученного байта, а не момент, когда JSON уже разобран приложением. Если API возвращает большой ответ, обработка JSON и запись в базу находятся уже за пределами этой шкалы и их измеряют отдельно.

<?php

function collectPartnerTrace($curl, $operationId, array $limits) {
    return array(
        "operation_id" => $operationId,
        "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),
        "app_connect" => curl_getinfo($curl, CURLINFO_APPCONNECT_TIME),
        "start_transfer" => curl_getinfo($curl, CURLINFO_STARTTRANSFER_TIME),
        "total" => curl_getinfo($curl, CURLINFO_TOTAL_TIME),
        "connect_limit" => $limits["connect"],
        "total_limit" => $limits["total"],
    );
}

$limits = array("connect" => 2, "total" => 8);
$curl = curl_init($partnerUrl);
curl_setopt_array($curl, array(
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CONNECTTIMEOUT => $limits["connect"],
    CURLOPT_TIMEOUT => $limits["total"],
));

$body = curl_exec($curl);
$trace = collectPartnerTrace($curl, $operationId, $limits);
curl_close($curl);

// Запишите $trace в свой журнал с маскировкой чувствительных полей.

Лог удобнее хранить как поля, а не как склеенную строку. Тогда можно отфильтровать все события с HTTP-кодом ноль и увидеть, какие из них упёрлись в connect limit. Но даже без отдельной системы метрик эти поля дают материал для ручного разбора нескольких случаев. Главное — сохранять их и при успехе, и при ошибке: иначе невозможно сравнить нормальный путь с проблемным.

Делаю задержки воспроизводимыми на локальном стенде

Нельзя ждать настоящего сбоя партнёра, чтобы проверить обработчик. В каталоге для теста можно запустить встроенный сервер PHP и создать два маршрута: один задерживает первый байт, другой пишет начало ответа, затем ждёт перед хвостом. Это не модель всего интернета; она нужна, чтобы увидеть разницу между start_transfer и total своим клиентом.

<?php
// router.php

$path = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
header("Content-Type: application/json");

if ($path === "/slow-first-byte") {
    usleep(4000000);
    echo json_encode(array("ok" => true));
    return;
}

if ($path === "/slow-body") {
    echo "{\"items\":[";
    flush();
    usleep(4000000);
    echo "1]}";
    return;
}

echo json_encode(array("ok" => true));

Запуск php -S 127.0.0.1:8080 router.php даёт адрес для клиента из предыдущего раздела. Для /slow-first-byte общий предел меньше четырёх секунд должен остановить вызов до ответа. Для /slow-body первый байт может появиться рано, а общий предел — позже. Поведение flush() зависит от SAPI и прокси, поэтому этот маршрут проверяют именно на своём локальном запуске, а не используют как доказательство поведения production-прокси.

Читаю временную шкалу как стадии, а не как независимые числа

Все времена cURL накопительные. Нельзя сложить name_lookup, connect и start_transfer: каждый отсчитывается от начала. Для приближённой длительности DNS смотрят name lookup. Для пути от DNS до TCP и TLS сравнивают более позднюю отметку с name lookup. Для ожидания приложения после установленного соединения сопоставляют start transfer с connect или app connect.

Наблюдение в traceЧто клиент может утверждатьЧто не следует утверждатьСледующий шаг
HTTP-код 0, connect близок к лимитуКлиент не получил HTTP-ответ и долго устанавливал соединениеЧто SQL партнёра медленныйПроверить DNS, маршрут, доступность и TLS
connect мал, start_transfer близок к totalСвязь установилась, но первый байт не пришёл вовремяЧто тело ответа слишком большоеПередать партнёру ID операции и его время ожидания
start_transfer мал, total близок к total limitОтвет начался, но перенос не завершился в бюджетеЧто проблема именно в DNSПроверить размер, буферизацию и скорость тела
HTTP-код 500 или 429Сервер успел ответить статусомЧто это транспортный timeoutОбработать статус по контракту API и его условиям повторов

В HTTPS app_connect помогает отделить завершение TLS от последующего ожидания ответа. На HTTP это поле может быть нулём. Нулевое поле нельзя интерпретировать как «TLS занял ноль секунд» без знания схемы URL. В trace полезно положить также схему и безопасно нормализованный host, но не секретные параметры запроса.

Разделяю проверку с партнёром и изменение клиента

Когда trace указывает на поздний первый байт, партнёру передают ID операции, время старта, URL-путь, пороги и фактические накопленные времена. Фраза «у вас тормозит» не помогает найти запрос. Когда проблема до соединения, сначала проверяют адрес, DNS и сертификаты со стороны клиента. Когда задерживается тело, сравнивают ожидаемый размер ответа с тем, что реально нужно экрану: возможно, вместо большого списка нужен фильтр или отдельная выгрузка.

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

Не запускаю повтор для неизвестного изменения

После timeout у POST клиент не знает, создал ли партнёр заявку до обрыва ответа. HTTP-стандарт различает идемпотентные методы потому, что одинаковый запрос с таким эффектом можно повторять после сбоя связи до чтения ответа. Но даже в 2018 году это не было разрешением считать любой вызов безопасным: бизнес-операция и её контракт важнее удобства очередного запуска.

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

<?php

function nextStepAfterTimeout($method, $hasPartnerOperationKey, $statusCanBeChecked) {
    if ($method === "GET" || $method === "HEAD") {
        return "one_limited_retry";
    }

    if ($hasPartnerOperationKey && $statusCanBeChecked) {
        return "check_operation_status";
    }

    return "mark_result_unknown";
}

Порядок полевого разбора

  1. Сохранить один trace с error, HTTP-кодом, порогами и накопленными временами; исключить из него токены, тело и персональные данные.
  2. Определить, есть ли HTTP-код и на какой отметке остановился путь: до соединения, до первого байта или после начала тела.
  3. Воспроизвести соответствующую задержку на локальном стенде, чтобы проверить, что клиент различает минимум два случая.
  4. Проверить одну внешнюю границу: DNS и TLS, ожидание обработчика партнёра либо объём и скорость ответа.
  5. Для изменения состояния не повторять вызов до проверки постоянного ключа операции и доступности запроса статуса.
  6. Изменить один предел или контракт ответа, снова снять trace и сравнить с исходным по той же стадии.

Ограничения метода

Trace с клиента не заменяет логи партнёра и не доказывает, где внутри его сервиса возникла задержка. Он также не показывает время работы PHP до вызова cURL или после разбора ответа. Встроенный сервер PHP годится только для учебной задержки; реальный балансировщик и буферизация могут менять момент первого байта. Если интеграция выполняется в очереди, её собственное время ожидания и число повторов нужно учитывать отдельно от HTTP-клиента.

Итог

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

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