Files
progcode/editorial/agent-rewrites/341.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
18 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 341,
"slug": "editorial-2018-07-mechanism-http-timeouts",
"title": "PHP cURL: как понять, на какой стадии сработал таймаут",
"excerpt": "Ошибка cURL 28 сообщает о сработавшем ограничении, но не называет стадию запроса. Разбираем connect timeout, общий предел, медленный ответ и условия безопасного повтора.",
"contentHtml": "<p>Симптом знакомый: страница ждёт ответ партнёрского API, PHP-процесс занимает worker, а в журнале появляется только <code>cURL error 28</code>. Команда увеличивает таймаут с десяти до шестидесяти секунд. Ошибка не исчезает. Пользователь ждёт дольше, пул PHP быстрее заполняется, а причина всё ещё неизвестна. Если запрос создаёт заказ или платёж, автоматический повтор может добавить вторую операцию.</p>\n<p>Рабочее правило такое: таймаут нужно читать как границу стадии, а не как общее название сбоя. <code>CURLOPT_CONNECTTIMEOUT</code> ограничивает начальную фазу соединения. <code>CURLOPT_TIMEOUT</code> ограничивает весь перенос. Пара <code>CURLOPT_LOW_SPEED_LIMIT</code> и <code>CURLOPT_LOW_SPEED_TIME</code> останавливает уже начавшийся слишком медленный перенос. Код 28 может быть итогом любого из этих условий.</p>\n<h2>Сначала отделяем стадии запроса</h2>\n<p>libcurl начинает с разрешения имени. Затем он устанавливает TCP-соединение и для HTTPS выполняет TLS-рукопожатие. Только после этого приложение партнёра получает шанс обработать HTTP-запрос. Поэтому connect timeout может закончиться на DNS, маршруте или TLS. Это не доказывает медленный SQL и не доказывает, что партнёр увидел запрос.</p>\n<p>После соединения libcurl ждёт первый байт ответа. Большая задержка здесь обычно означает очередь, обработчик или другой промежуточный слой. Когда первый байт уже пришёл, начинается получение тела. Тело может идти медленно из-за размера ответа, прокси или канала. Эти случаи требуют разных проверок.</p>\n<figure><img src='/assets/editorial/2018/http-timeout-layers-2018.svg' alt='Стадии HTTP-запроса libcurl: соединение, ожидание первого байта и получение тела' /><figcaption>Один HTTP-вызов имеет несколько границ. Общий предел охватывает путь целиком, а специальные условия помогают остановить конкретную проблему.</figcaption></figure>\n<h2>Что именно ограничивают опции cURL</h2>\n<p><code>CURLOPT_CONNECTTIMEOUT</code> задаёт предел начальной фазы соединения. Для HTTPS в неё входит TLS. Значение не прибавляется к общему пределу. Если общий timeout равен двум секундам, а connect timeout — четырём, вызов завершится не позднее двух секунд. Общий предел уже истёк, даже если соединение ещё не установилось.</p>\n<p><code>CURLOPT_TIMEOUT</code> действует от начала до конца переноса. Он включает соединение, ожидание ответа и чтение тела. Это бюджет всего синхронного сценария. Его связывают с тем, сколько времени экран или вызывающий сервис вправе ждать внешний результат. Учебные значения из примера ниже не являются рекомендацией для production.</p>\n<p>У обычного вызова нет отдельной универсальной опции «ждать чтения ещё N секунд после первого байта». Пара low speed отвечает на другую задачу. Она завершает перенос, если средняя скорость опускается ниже выбранного порога в течение выбранного времени. Такой порог может остановить зависший поток, но может также прервать легитимно медленную выгрузку.</p>\n<pre><code>&lt;?php\n\n$curl = curl_init('https://partner.example/api/catalog');\ncurl_setopt_array($curl, array(\n CURLOPT_RETURNTRANSFER =&gt; true,\n CURLOPT_CONNECTTIMEOUT =&gt; 2,\n CURLOPT_TIMEOUT =&gt; 8,\n CURLOPT_LOW_SPEED_LIMIT =&gt; 100,\n CURLOPT_LOW_SPEED_TIME =&gt; 3,\n));\n\n$body = curl_exec($curl);\n$errno = curl_errno($curl);\n$error = curl_error($curl);\n$info = curl_getinfo($curl);\ncurl_close($curl);\n\n// Учебный пример: числа нужно проверить на контракте конкретного API.\n// Код 28 сам по себе не называет стадию таймаута.</code></pre>\n<p>Вызов выше задаёт общий бюджет восемь секунд и более короткую границу соединения. Если ответ партнёра начался, но почти застыл, low speed может остановить его раньше общего предела. Смысл настройки виден только вместе с измерениями и журналом. Одного числа в конфигурации недостаточно.</p>\n<h2>Как читать временную шкалу</h2>\n<p>После <code>curl_exec</code> вызов <code>curl_getinfo</code> возвращает накопленные отметки времени. <code>CURLINFO_NAMELOOKUP_TIME</code> показывает время от старта до завершения разрешения имени. <code>CURLINFO_CONNECT_TIME</code> — время до установления соединения. Для HTTPS <code>CURLINFO_APPCONNECT_TIME</code> может показать завершение TLS. <code>CURLINFO_STARTTRANSFER_TIME</code> — время до первого байта. <code>CURLINFO_TOTAL_TIME</code> — весь перенос.</p>\n<p>Это накопленные значения, а не длительности отдельных участков. Если соединение завершилось за 0,20 секунды, а первый байт пришёл за 6,80, ожидание после соединения заняло примерно 6,60 секунды. Вычитание помогает назвать стадию. Нулевой <code>APPCONNECT_TIME</code> для обычного HTTP не означает ошибку: TLS там нет.</p>\n<pre><code>&lt;?php\n\nfunction transferTimes($curl) {\n return array(\n 'name_lookup' =&gt; curl_getinfo($curl, CURLINFO_NAMELOOKUP_TIME),\n 'connect' =&gt; curl_getinfo($curl, CURLINFO_CONNECT_TIME),\n 'app_connect' =&gt; curl_getinfo($curl, CURLINFO_APPCONNECT_TIME),\n 'start_transfer' =&gt; curl_getinfo($curl, CURLINFO_STARTTRANSFER_TIME),\n 'total' =&gt; curl_getinfo($curl, CURLINFO_TOTAL_TIME),\n );\n}</code></pre>\n<p>Учебный рисунок <code>connect = 0.18</code>, <code>start_transfer = 7.91</code>, <code>total = 8.00</code> означает: соединение прошло быстро, первый байт не пришёл до общего предела. Рисунок <code>start_transfer = 0.30</code>, <code>total = 8.00</code> означает другое: тело не завершилось в пределах бюджета. Это примеры формы диагностики, а не результаты конкретного production-сервиса.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class='table-scroll'><table><thead><tr><th scope='col'>Симптом</th><th scope='col'>Вероятная причина</th><th scope='col'>Проверка</th><th scope='col'>Действие</th></tr></thead><tbody><tr><td>Код 28, HTTP-код 0, не завершился connect</td><td>DNS, маршрут, TCP или TLS</td><td>Сопоставить NAMELOOKUP, CONNECT, APPCONNECT и проверить узел из той же сети</td><td>Исправить адрес или сетевой путь; не увеличивать общий timeout вслепую</td></tr><tr><td>Соединение быстрое, первый байт почти у общего предела</td><td>Очередь или обработчик партнёра</td><td>Сравнить CONNECT и STARTTRANSFER; проверить серверный request ID</td><td>Разобрать SLA партнёра или перейти на асинхронную операцию</td></tr><tr><td>Первый байт пришёл быстро, тело идёт до лимита</td><td>Большой ответ, прокси или низкая скорость</td><td>Сравнить STARTTRANSFER и TOTAL, размер тела и LOW_SPEED-порог</td><td>Сократить ответ, получать его частями или изменить обоснованный бюджет</td></tr><tr><td>HTTP-код не ноль, но клиент сообщает ошибку контракта</td><td>Сервер ответил, проблема не в транспортном таймауте</td><td>Сохранить статус, заголовки без секретов и тело по правилам маскирования</td><td>Исправить обработку HTTP-кода; не лечить его настройкой cURL</td></tr><tr><td>После 28 хочется повторить POST</td><td>Состояние операции неизвестно</td><td>Проверить идемпотентность, ключ операции и endpoint статуса</td><td>Сначала запросить статус; повторять только по договору API</td></tr></tbody></table></div>\n<p>Лог должен позволять пройти эту таблицу без догадок. Записывайте метод, путь без секретных параметров, корреляционный идентификатор, cURL error, HTTP-код, установленные пороги, размер полученного тела и временные отметки. Пароли, токены и полный чувствительный ответ в общий журнал не кладите.</p>\n<h2>Почему error 28 не даёт готового диагноза</h2>\n<p><code>CURLE_OPERATION_TIMEDOUT</code> означает, что достигнуто условие таймаута. Код не различает DNS, ожидание первого байта и медленное тело. Строка <code>curl_error</code> может дать дополнительное описание, но она тоже не заменяет временную шкалу. Сохраняйте ошибку вместе с конфигурацией вызова. Иначе через неделю нельзя будет понять, какой предел сработал.</p>\n<p>HTTP-код равен нулю, если libcurl не получил HTTP-статус. Ненулевой код означает, что до приложения дошёл ответ с HTTP-статусом, даже если этот статус ошибочный. Это не абсолютная модель всех прокси и обрывов, поэтому данные нужно читать вместе с логами, но она сразу отделяет отсутствие ответа от ответа с ошибкой.</p>\n<h2>Повторять можно не ошибку, а безопасную операцию</h2>\n<p>Таймаут завершает наблюдение клиента. Он не доказывает, что сервер не выполнил запрос. Сервер мог сохранить заказ и потерять соединение перед отправкой ответа. Если клиент повторит создание, появится дубль.</p>\n<p>Безопасный автоматический повтор требует идемпотентной семантики. GET и PUT обычно относятся к идемпотентным методам по HTTP, но конкретный API может добавлять побочные эффекты. POST нельзя считать безопасным только по названию. Его можно повторять, если API документирует ключ операции, дедупликацию и одинаковый результат для повторных попыток. Ключ должен сохраняться между попытками.</p>\n<p>Если договор не даёт такой гарантии, после timeout сначала проверяют статус операции по внешнему идентификатору. Если endpoint статуса отсутствует, событие переводят в ручную или отложенную обработку. Увеличение таймаута не решает неизвестное состояние.</p>\n<h2>Порядок проверки</h2>\n<ol><li>Опишите бюджет сценария: сколько экран или вызывающий сервис может ждать внешний ответ.</li><li>Задайте отдельный connect timeout, который меньше либо равен общему пределу.</li><li>В тестовой среде воспроизведите отсутствие соединения, задержку до первого байта и медленное тело раздельно.</li><li>Для каждого запуска сохраните cURL error, HTTP-код, пороги и NAMELOOKUP, CONNECT, APPCONNECT, STARTTRANSFER, TOTAL.</li><li>Сопоставьте временную шкалу с одной стадией. Не меняйте DNS, общий timeout и retry одновременно.</li><li>Перед повтором проверьте метод, ключ операции и endpoint статуса.</li><li>Измените только подтверждённую границу и повторите тот же тестовый сценарий.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Времена libcurl описывают путь клиента. Они не показывают внутреннюю очередь партнёра, его базу, работу CDN или прокси. Повторное соединение может сделать connect time маленьким. Параллельный вызов имеет отдельную очередь. Эти случаи требуют дополнительных метрик.</p>\n<p>Разбор готов, когда для каждого тестового таймаута команда может назвать стадию, показать соответствующие временные отметки и объяснить, почему выбранное действие не создаёт вторую операцию. Для production-настройки дополнительно нужны измеренный размер ответа, допустимый бюджет сценария, безопасный журнал и документированный контракт повтора. Пока хотя бы один из этих пунктов отсутствует, число timeout остаётся предположением.</p>\n<h2>Проверяемые источники</h2><ul><li><a href='https://curl.se/libcurl/c/CURLOPT_CONNECTTIMEOUT.html' target='_blank' rel='noopener noreferrer'>libcurl: CURLOPT_CONNECTTIMEOUT</a> — границы фазы соединения и её связь с общим пределом.</li><li><a href='https://curl.se/libcurl/c/CURLOPT_TIMEOUT.html' target='_blank' rel='noopener noreferrer'>libcurl: CURLOPT_TIMEOUT</a> — общий timeout от начала до конца переноса.</li><li><a href='https://datatracker.ietf.org/doc/html/rfc9110#section-9.2.2' target='_blank' rel='noopener noreferrer'>RFC 9110, раздел 9.2.2</a> — идемпотентность и повтор после сбоя связи.</li></ul>"
}