Ночная синхронизация сообщает только «таймаут партнёра», а утром неизвестно: DNS не ответил, TLS не установился, партнёр не начал ответ или тело выгрузки шло слишком долго. Если в такой момент запустить задачу повторно, можно одновременно нагрузить недоступный узел и дважды отправить изменение. Цена ошибки — не только сорванная выгрузка, но и неизвестное состояние данных.
Ниже учебный полевой маршрут для PHP 2018: как добавить к одному cURL-вызову наблюдаемые стадии, воспроизвести три типа задержки на локальном стенде и принять решение без догадки. Числа и строки лога здесь являются форматом примера, а не заявлением о результатах чужого сервиса.
Сначала фиксирую то, что клиент действительно видел
В журнал нельзя писать только фразу «curl timeout». Нужны как минимум метод, безопасный идентификатор операции, cURL error, HTTP-код, пороги клиента и временные отметки. HTTP-код ноль означает, что cURL не получил HTTP-статус от целевого сервера; ненулевой код отделяет транспортную проблему от ответа, который успел сформироваться. Полный URL с токеном, тело запроса и ответ партнёра в общий лог не кладём.
Для одной операции нужен постоянный идентификатор. Он помогает сопоставить клиентский лог с логом партнёра и не должен быть случайно создан заново при повторе. Это ещё не идемпотентность: ключ становится защитой только тогда, когда API партнёра принимает его и документированно связывает с одной операцией.
Собираю одну строку диагностики после 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";
}
Порядок полевого разбора
- Сохранить один trace с error, HTTP-кодом, порогами и накопленными временами; исключить из него токены, тело и персональные данные.
- Определить, есть ли HTTP-код и на какой отметке остановился путь: до соединения, до первого байта или после начала тела.
- Воспроизвести соответствующую задержку на локальном стенде, чтобы проверить, что клиент различает минимум два случая.
- Проверить одну внешнюю границу: DNS и TLS, ожидание обработчика партнёра либо объём и скорость ответа.
- Для изменения состояния не повторять вызов до проверки постоянного ключа операции и доступности запроса статуса.
- Изменить один предел или контракт ответа, снова снять trace и сравнить с исходным по той же стадии.
Ограничения метода
Trace с клиента не заменяет логи партнёра и не доказывает, где внутри его сервиса возникла задержка. Он также не показывает время работы PHP до вызова cURL или после разбора ответа. Встроенный сервер PHP годится только для учебной задержки; реальный балансировщик и буферизация могут менять момент первого байта. Если интеграция выполняется в очереди, её собственное время ожидания и число повторов нужно учитывать отдельно от HTTP-клиента.
Итог
Полевой разбор зависшей интеграции начинается с одной наблюдаемой строки: error, HTTP-код, пороги и стадии cURL. По ней отделяют отсутствие соединения от позднего первого байта и медленного тела. После этого меняют одну границу или проверяют конкретный узел. Повтор операции остаётся отдельным решением, зависящим от её семантики и известного состояния, а не от того, что cURL вернул timeout.
Проверяемые источники
- libcurl: CURLOPT_CONNECTTIMEOUT — состав фазы соединения, включение DNS и протокольных переговоров, соотношение с общим таймаутом
- libcurl: CURLOPT_TIMEOUT — жёсткий предел всего переноса от начала до конца и включение connect timeout в этот предел
- libcurl: CURLOPT_LOW_SPEED_LIMIT — средняя скорость, ниже которой перенос считается слишком медленным совместно с LOW_SPEED_TIME
- libcurl: CURLOPT_LOW_SPEED_TIME — длительность низкой скорости и завершение переноса с ошибкой таймаута
- libcurl: Error Codes — значение CURLE_OPERATION_TIMEDOUT (28): достигнуто одно из условий таймаута
- libcurl: curl_easy_getinfo и временные отметки — смысл NAMELOOKUP, CONNECT, APPCONNECT, STARTTRANSFER и TOTAL после переноса
- RFC 7231, раздел 4.2.2 — идемпотентные методы — документ, действовавший в 2018 году; определяет идемпотентность и повтор после сбоя связи до чтения ответа
- RFC 9110, раздел 9.2.2 — идемпотентные методы — актуальная редакция HTTP Semantics; запрещает угадывать безопасный автоматический повтор неидемпотентного запроса