499 lines
56 KiB
JavaScript
499 lines
56 KiB
JavaScript
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const escapeHtml = (value) => String(value)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
|
||
const paragraph = (content) => '<p>' + content + '</p>';
|
||
const heading = (content) => '<h2>' + content + '</h2>';
|
||
const codeBlock = (source) => '<pre><code>' + escapeHtml(source.trim()) + '</code></pre>';
|
||
const figure = (src, alt, caption) => [
|
||
'<figure>',
|
||
'<img src="' + src + '" alt="' + alt + '" />',
|
||
'<figcaption>' + caption + '</figcaption>',
|
||
'</figure>',
|
||
].join('');
|
||
|
||
function dataTable(headers, rows) {
|
||
const tableHead = headers.map((header) => '<th scope="col">' + header + '</th>').join('');
|
||
const tableBody = rows.map((row) => (
|
||
'<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>'
|
||
)).join('');
|
||
|
||
return '<div class="table-scroll"><table><thead><tr>' + tableHead
|
||
+ '</tr></thead><tbody>' + tableBody + '</tbody></table></div>';
|
||
}
|
||
|
||
function orderedList(items) {
|
||
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||
}
|
||
|
||
function sourceList(items) {
|
||
return heading('Проверяемые источники') + '<ul>' + items.map(({ label, url, note }) => (
|
||
'<li><a href="' + url + '" target="_blank" rel="noopener noreferrer">'
|
||
+ label + '</a> — ' + note + '</li>'
|
||
)).join('') + '</ul>';
|
||
}
|
||
|
||
function visibleText(html) {
|
||
return html
|
||
.replace(/<[^>]*>/g, ' ')
|
||
.replaceAll(' ', ' ')
|
||
.replaceAll('"', '"')
|
||
.replaceAll(''', "'")
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('&', '&')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
function createRevision(meta, bodyParts, sources) {
|
||
const bodyHtml = bodyParts.join('\n');
|
||
const bodyLength = visibleText(bodyHtml).length;
|
||
const contentHtml = bodyHtml + '\n' + sourceList(sources);
|
||
|
||
if (bodyLength < 5000 || bodyLength > 15000) {
|
||
throw new Error(meta.slug + ': body length must be 5000–15000, got ' + bodyLength);
|
||
}
|
||
|
||
if ((bodyHtml.match(/<h2>/g) || []).length < 5) {
|
||
throw new Error(meta.slug + ': at least five h2 headings are required');
|
||
}
|
||
|
||
for (const fragment of ['<figure>', '<table>', '<pre><code>', '<ol>']) {
|
||
if (!bodyHtml.includes(fragment)) {
|
||
throw new Error(meta.slug + ': missing ' + fragment);
|
||
}
|
||
}
|
||
|
||
if (!contentHtml.includes('<h2>Проверяемые источники</h2>') || sources.length < 2) {
|
||
throw new Error(meta.slug + ': primary source section is incomplete');
|
||
}
|
||
|
||
return {
|
||
...meta,
|
||
contentHtml,
|
||
};
|
||
}
|
||
|
||
const sources = {
|
||
connectTimeout: {
|
||
label: 'libcurl: CURLOPT_CONNECTTIMEOUT',
|
||
url: 'https://curl.se/libcurl/c/CURLOPT_CONNECTTIMEOUT.html',
|
||
note: 'состав фазы соединения, включение DNS и протокольных переговоров, соотношение с общим таймаутом',
|
||
},
|
||
timeout: {
|
||
label: 'libcurl: CURLOPT_TIMEOUT',
|
||
url: 'https://curl.se/libcurl/c/CURLOPT_TIMEOUT.html',
|
||
note: 'жёсткий предел всего переноса от начала до конца и включение connect timeout в этот предел',
|
||
},
|
||
lowSpeedLimit: {
|
||
label: 'libcurl: CURLOPT_LOW_SPEED_LIMIT',
|
||
url: 'https://curl.se/libcurl/c/CURLOPT_LOW_SPEED_LIMIT.html',
|
||
note: 'средняя скорость, ниже которой перенос считается слишком медленным совместно с LOW_SPEED_TIME',
|
||
},
|
||
lowSpeedTime: {
|
||
label: 'libcurl: CURLOPT_LOW_SPEED_TIME',
|
||
url: 'https://curl.se/libcurl/c/CURLOPT_LOW_SPEED_TIME.html',
|
||
note: 'длительность низкой скорости и завершение переноса с ошибкой таймаута',
|
||
},
|
||
errors: {
|
||
label: 'libcurl: Error Codes',
|
||
url: 'https://curl.se/libcurl/c/libcurl-errors.html',
|
||
note: 'значение CURLE_OPERATION_TIMEDOUT (28): достигнуто одно из условий таймаута',
|
||
},
|
||
getinfo: {
|
||
label: 'libcurl: curl_easy_getinfo и временные отметки',
|
||
url: 'https://curl.se/libcurl/c/curl_easy_getinfo.html',
|
||
note: 'смысл NAMELOOKUP, CONNECT, APPCONNECT, STARTTRANSFER и TOTAL после переноса',
|
||
},
|
||
rfc7231: {
|
||
label: 'RFC 7231, раздел 4.2.2 — идемпотентные методы',
|
||
url: 'https://datatracker.ietf.org/doc/html/rfc7231#section-4.2.2',
|
||
note: 'документ, действовавший в 2018 году; определяет идемпотентность и повтор после сбоя связи до чтения ответа',
|
||
},
|
||
rfc9110: {
|
||
label: 'RFC 9110, раздел 9.2.2 — идемпотентные методы',
|
||
url: 'https://datatracker.ietf.org/doc/html/rfc9110#section-9.2.2',
|
||
note: 'актуальная редакция HTTP Semantics; запрещает угадывать безопасный автоматический повтор неидемпотентного запроса',
|
||
},
|
||
};
|
||
|
||
const practiceArticle = createRevision(
|
||
{
|
||
slug: 'editorial-2018-07-practice-http-timeouts',
|
||
title: 'PHP cURL. Как дать веб-интеграции ограниченный бюджет времени',
|
||
categories: ['PHP', 'cURL', 'Интеграции'],
|
||
cover: '/assets/editorial/2018/http-timeout-budget-2018.svg',
|
||
excerpt: 'У одного запроса к партнёру должен быть общий предел, короткая граница соединения и понятное правило повтора. Разбираем это на PHP cURL без бесконечного ожидания и двойных операций.',
|
||
readingMinutes: 11,
|
||
},
|
||
[
|
||
paragraph('Симптом: карточка товара ждёт цену от партнёра, а PHP-процесс висит до лимита веб-сервера. В это время заняты рабочие процессы, пользователь получает пустой блок, а следующий разработчик увеличивает общий таймаут до минуты. Цена такой правки — больше занятых процессов и та же неизвестная причина сбоя.'),
|
||
paragraph('В этой заметке разберём один узкий вопрос: как задать бюджеты ожидания для PHP cURL-запроса, записать результат и решить, когда его вообще можно повторить. Значения ниже учебные. Их нельзя переносить в другой API без размера ответа, ожидаемой нагрузки и договора с партнёром.'),
|
||
heading('Сначала задаю время, которое можно отдать партнёру'),
|
||
paragraph('Таймаут — это не число, которое берут из чужого примера. Сначала у сценария появляется предел. Допустим, страница готова ждать внешний остаток восемь секунд. Внутри этих восьми секунд соединению дадим две секунды. Оставшееся время занимает ожидание первого байта и получение тела. Если партнёр не уложился, текущая страница завершает свой путь, а не держит PHP бесконечно.'),
|
||
paragraph('Такой расчёт не доказывает, что восемь секунд хороши для всех. Он делает решение проверяемым: в журнале можно увидеть, на какой части пути ушло время, и поменять конкретную границу. Общий предел особенно важен, потому что без него успешно открытое соединение всё ещё может ждать ответ или тело сколько угодно долго.'),
|
||
figure(
|
||
'/assets/editorial/2018/http-timeout-budget-2018.svg',
|
||
'Шкала HTTP-запроса с отдельной границей соединения и общим пределом всего переноса.',
|
||
'Connect timeout ограничивает начальную фазу, общий timeout охватывает её вместе с ожиданием и телом ответа.',
|
||
),
|
||
heading('Разделяю три разных вопроса'),
|
||
paragraph('У cURL есть несколько настроек, которые часто называют одним словом timeout. У них разная работа. Если смешать их в одну цифру, из лога с ошибкой 28 нельзя понять, был ли недоступен адрес, долго ли отвечал сервер или ответ передавался слишком медленно. Поэтому у каждого ограничения должна быть собственная причина появления.'),
|
||
dataTable(
|
||
['Вопрос', 'Настройка', 'Что ограничивает', 'Что проверять при срабатывании'],
|
||
[
|
||
['Успели ли открыть соединение?', '<code>CURLOPT_CONNECTTIMEOUT</code>', 'DNS, TCP и переговоры протокола до установленного соединения', 'Адрес, DNS, сеть, TLS и короткий путь до партнёра'],
|
||
['Успел ли закончиться весь запрос?', '<code>CURLOPT_TIMEOUT</code>', 'Весь перенос от старта до конца, включая соединение', 'Бюджет сценария, ожидание первого байта и размер тела'],
|
||
['Не течёт ли ответ слишком медленно?', '<code>CURLOPT_LOW_SPEED_LIMIT</code> + <code>CURLOPT_LOW_SPEED_TIME</code>', 'Среднюю скорость ниже порога в течение заданного времени', 'Размер ответа, прокси-буферизацию и реальную скорость передачи'],
|
||
['Можно ли попробовать ещё раз?', 'Код приложения', 'Бизнес-операцию, метод и оставшееся время', 'Идемпотентность и состояние операции у партнёра'],
|
||
],
|
||
),
|
||
paragraph('Официальная документация libcurl прямо включает DNS и все переговоры до установленного соединения в connect phase. Она также говорит, что этот короткий предел находится внутри общего timeout. Поэтому connect timeout не складывают с total timeout: при двух и восьми секундах максимум всего вызова всё равно восемь, а не десять.'),
|
||
heading('Минимальная настройка PHP cURL'),
|
||
paragraph('Ниже функция не пытается решить бизнес-логику за приложение. Она возвращает тело, ошибку, HTTP-код и времена. Важная деталь: HTTP-код читаем отдельно от ошибки cURL. Сервер может ответить 500 быстро; это HTTP-ответ, а не таймаут транспорта. При сетевом обрыве HTTP-код обычно останется нулём.'),
|
||
codeBlock([
|
||
'<?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;',
|
||
'}',
|
||
].join('\n')),
|
||
paragraph('Пара low speed limit и low speed time нужна не как красивое третье число. Она подходит для ответа, который уже начался, но его средняя скорость долго остаётся ниже порога. В примере граница равна 100 байтам в секунду в течение трёх секунд. Для короткого JSON это может быть разумным сигналом зависания; для выгрузки большого файла такой порог нужно выбирать отдельно.'),
|
||
heading('Не называю общий timeout read timeout'),
|
||
paragraph('У простого вызова libcurl нет одной настройки, которая буквально означает «не ждать чтения N секунд». <code>CURLOPT_TIMEOUT</code> ограничивает весь перенос. Пара low speed ограничивает среднюю скорость передачи за период. Это близкий практический контроль для зависшего тела, но не тот же самый механизм. В тексте, логе и настройках лучше называть вещи своими именами — иначе следующая проверка окажется неверной.'),
|
||
paragraph('Если <code>start_transfer</code> близок к восьми секундам, ответ долго не начинался: смотреть нужно очередь и обработку у партнёра после успешного соединения. Если первый байт пришёл быстро, а <code>total</code> упёрся в потолок, граница уже в теле ответа, сжатии или канале. Если <code>connect</code> близок к двум секундам и HTTP-код ноль, повышать время ожидания ответа бессмысленно: сначала проверяют адрес и соединение.'),
|
||
heading('Повторяю только чтение или известную операцию'),
|
||
paragraph('Ошибка timeout не говорит, что партнёр ничего не сделал. Запрос мог дойти до сервера, операция могла выполниться, а ответ потеряться. Поэтому нельзя после любого <code>POST</code> просто вызвать ту же функцию ещё раз: заказ, платёж или заявка могут появиться дважды. HTTP различает методы по предполагаемому эффекту; в документе, действовавшем в 2018 году, GET, HEAD, PUT и DELETE имеют идемпотентную семантику, но конкретный API всё равно может иметь побочные действия вокруг них.'),
|
||
paragraph('Для чтения можно оставить один контролируемый повтор, если ещё хватает времени на полезный ответ. Для изменения состояния нужен договор с партнёром: постоянный ключ операции, поиск состояния по нему или другой способ доказать, что первый вызов не был применён. Пока такого договора нет, результат таймаута следует считать неопределённым и передать на проверку, а не создавать второй объект.'),
|
||
codeBlock([
|
||
'<?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 требует отдельного контракта ключа операции у партнёра.',
|
||
].join('\n')),
|
||
heading('Порядок ввода в проект'),
|
||
orderedList([
|
||
'Назвать пользовательский сценарий и записать его внешний бюджет: сколько секунд можно ждать именно этому экрану или задаче.',
|
||
'Поставить общий timeout меньше лимита PHP и короткий connect timeout внутри него; не суммировать их.',
|
||
'Вернуть из клиента ошибку cURL, HTTP-код и несколько временных отметок, не записывая в журнал тело с персональными данными.',
|
||
'На тестовом адресе проверить отдельно: недоступный хост, задержку до первого байта и медленное тело ответа.',
|
||
'Для каждого метода зафиксировать правило повтора: GET и HEAD могут иметь один контролируемый повтор, изменение состояния — только после договора о ключе операции или проверке статуса.',
|
||
'После первых журналов менять одну границу и повторять тот же сценарий, а не поднимать все таймауты одновременно.',
|
||
]),
|
||
heading('Ограничения примера'),
|
||
paragraph('Числа два, восемь, сто и три не являются нормативом. На выбор влияют число параллельных PHP-процессов, размер ожидаемого тела, повторное использование соединения, прокси и пользовательский сценарий. <code>CURLINFO_TOTAL_TIME</code> показывает длительность завершившегося переноса; он не заменяет отдельный замер очереди веб-сервера до входа в PHP. Если приложение идёт через несколько прокси, у каждого может быть свой предел ожидания, который тоже нужно знать.'),
|
||
heading('Итог'),
|
||
paragraph('Рабочая настройка начинается не с увеличения одного timeout. У запроса есть общий бюджет, короткая граница соединения и при необходимости контроль слишком медленной передачи. В журнале остаются error, HTTP-код и времена. После этого видно, где искать проблему и имеет ли право код сделать ещё одну попытку.'),
|
||
],
|
||
[
|
||
sources.connectTimeout,
|
||
sources.timeout,
|
||
sources.lowSpeedLimit,
|
||
sources.lowSpeedTime,
|
||
sources.getinfo,
|
||
sources.rfc7231,
|
||
sources.rfc9110,
|
||
],
|
||
);
|
||
|
||
const mechanismArticle = createRevision(
|
||
{
|
||
slug: 'editorial-2018-07-mechanism-http-timeouts',
|
||
title: 'PHP cURL. Почему connect, общий timeout и медленный ответ нельзя смешивать',
|
||
categories: ['PHP', 'cURL', 'HTTP'],
|
||
cover: '/assets/editorial/2018/http-timeout-layers-2018.svg',
|
||
excerpt: 'Один error 28 не рассказывает, что именно не успело: DNS и TLS, ожидание первого байта или тело ответа. Разбираем границы опций libcurl и безопасные условия для повтора.',
|
||
readingMinutes: 11,
|
||
},
|
||
[
|
||
paragraph('В журнале появляется <code>curl_errno = 28</code>, и его называют read timeout. После этого общий предел увеличивают с десяти до шестидесяти секунд. Ошибка остаётся, но PHP-процессы ждут в шесть раз дольше — цена неверного названия уже измеряется занятым пулом и медленным экраном.'),
|
||
paragraph('Разберём механизм по частям: что cURL считает соединением, где находится общий предел, чем на самом деле служит low speed и почему error 28 не даёт разрешение повторить запрос. Это уровень PHP 2018: обычный синхронный вызов <code>curl_exec</code>, без поздних терминов и без обещания, что одна настройка исправит партнёра.'),
|
||
heading('Connect phase начинается раньше TCP'),
|
||
paragraph('В документации libcurl connect phase начинается с разрешения имени. В неё входят DNS, TCP и последующие протокольные переговоры, пока не появится установленное соединение с удалённой стороной. Для HTTPS сюда попадёт и TLS-рукопожатие. Поэтому медленный DNS легко проявится как превышение connect timeout, хотя сам TCP-пакет ещё не посылался.'),
|
||
paragraph('Это полезная граница расследования. Если cURL не успел пройти connect phase, у приложения ещё нет HTTP-ответа партнёра. Проверяют URL, DNS, маршрут, сертификат и доступность конечного узла. Не стоит сразу искать медленный SQL на стороне API: до его обработчика запрос мог вообще не дойти.'),
|
||
figure(
|
||
'/assets/editorial/2018/http-timeout-layers-2018.svg',
|
||
'Три условия остановки HTTP-вызова libcurl: connect phase, общий перенос и низкая скорость ответа.',
|
||
'Connect timeout охватывает начальную фазу, total timeout — весь перенос, low speed — среднюю скорость ниже выбранного порога.',
|
||
),
|
||
heading('Общий timeout накрывает соединение'),
|
||
paragraph('Общий <code>CURLOPT_TIMEOUT</code> ограничивает весь перенос от старта до конца. Он не запускается после успешного соединения, а действует с самого начала. Документация libcurl приводит прямой пример: если connect timeout равен четырём секундам, а общий — двум, весь вызов остановится не позже двух. Из двух ограничений побеждает более ранняя граница.'),
|
||
paragraph('Из этого следует простой контракт конфигурации: connect timeout всегда меньше или равен общему, а общий соответствует бюджету сценария. Если экран может ждать восемь секунд, ставить connect восемь и total восемь обычно не помогает отличить «не установили связь» от «партнёр долго отвечает». Короткая отдельная граница нужна для первой стадии, а не для сложения секунд.'),
|
||
dataTable(
|
||
['Стадия', 'Что завершилось', 'Полезные поля после curl_exec', 'Следующая проверка'],
|
||
[
|
||
['До соединения', 'Нет подтверждённого соединения с узлом', '<code>name_lookup</code>, <code>connect</code>, <code>app_connect</code>, HTTP-код 0', 'DNS, маршрут, TLS, адрес партнёра'],
|
||
['После соединения, до ответа', 'Соединение есть, первый байт не пришёл', '<code>connect</code> мал, <code>start_transfer</code> близок к total', 'Очередь или обработчик на стороне партнёра'],
|
||
['После первого байта', 'Ответ начал идти, но не завершился', '<code>start_transfer</code> мал, <code>total</code> близок к пределу', 'Размер ответа, прокси, скорость и формат выгрузки'],
|
||
['HTTP-ошибка', 'Сервер успел прислать статус', 'HTTP-код не ноль, cURL может не иметь транспортной ошибки', 'Контракт конкретного статуса, а не таймауты'],
|
||
],
|
||
),
|
||
heading('Read timeout в этой конфигурации не отдельная ручка'),
|
||
paragraph('В PHP cURL часто ищут настройку «таймаут чтения». Для простого вызова её лучше не выдумывать. <code>CURLOPT_TIMEOUT</code> — общий жёсткий потолок. <code>CURLOPT_LOW_SPEED_LIMIT</code> вместе с <code>CURLOPT_LOW_SPEED_TIME</code> говорит другое: средняя скорость переноса была ниже заданного числа байтов в секунду в течение выбранного времени, поэтому перенос остановлен.'),
|
||
paragraph('Этот контроль полезен для ситуации «ответ начал идти, затем почти застыл». Но он может оборвать и легитимно медленную выгрузку, если порог выбран без знания размера и канала. Он не является проверкой того, что партнёр вообще не начал работу. На длинных отчётах лучше менять контракт — отдавать задачу асинхронно или получать результат частями, а не ставить слишком большой общий предел на страницу.'),
|
||
codeBlock([
|
||
'<?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 нельзя определить стадию без сохранённых времён.',
|
||
].join('\n')),
|
||
heading('Сохраняю временную шкалу, а не одно название ошибки'),
|
||
paragraph('После завершения переноса cURL даёт накопленные времена. Они не являются отдельными независимыми интервалами: каждое значение отсчитывается от начала запроса. Например, <code>CONNECT_TIME</code> — время от старта до подключения, а <code>STARTTRANSFER_TIME</code> — время от старта до первого полученного байта. Чтобы увидеть длительность участка, соседние накопленные отметки вычитают.'),
|
||
paragraph('Для HTTPS пригодится <code>CURLINFO_APPCONNECT_TIME</code>. На обычном HTTP он может быть нулевым, и это не ошибка. В PHP старого проекта стоит проверять доступность нужных констант на установленной версии расширения, а не копировать набор полей из новой документации. Базовые NAMELOOKUP, CONNECT, STARTTRANSFER и TOTAL существовали задолго до 2018 года и дают достаточно материала для первой диагностики.'),
|
||
codeBlock([
|
||
'<?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);',
|
||
'}',
|
||
].join('\n')),
|
||
paragraph('Если в логе <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> — указывает уже на передачу тела. Эти два случая нельзя лечить одной и той же настройкой.'),
|
||
heading('Error 28 описывает итог, а не причину'),
|
||
paragraph('Официальный список ошибок libcurl определяет <code>CURLE_OPERATION_TIMEDOUT</code> как достижение указанного условия таймаута. Он не записывает в код 28 отдельную метку «DNS», «сервер» или «тело ответа». Поэтому в одно событие диагностики кладут URL без секретной строки запроса, метод, пороги, error, HTTP-код, длину полученного тела и временную шкалу. Без сохранённых порогов даже хорошие времена нельзя сопоставить с решением клиента.'),
|
||
paragraph('Не стоит логировать пароль, токен или полный JSON только ради расследования. В большинстве случаев достаточно пути API, корреляционного идентификатора, кода ошибки, HTTP-кода и чисел времени. Если нужен фрагмент тела для контракта ошибки, его согласуют отдельно и маскируют. Диагностика не должна превращать таймаут в утечку данных.'),
|
||
heading('Повтор относится к операции, а не к error 28'),
|
||
paragraph('Документ HTTP, действовавший в 2018 году, различает идемпотентные методы: повтор одинакового запроса должен иметь тот же предполагаемый эффект, что и один вызов. Он объясняет, почему после сбоя связи до чтения ответа клиент может повторить такую операцию. Актуальный RFC 9110 добавляет важную границу: клиент не должен автоматически повторять неидемпотентный запрос, если не знает, что семантика конкретной операции идемпотентна или что первый запрос точно не был применён.'),
|
||
paragraph('Метод сам по себе не заменяет договор API. <code>POST /orders</code> без ключа операции не стоит повторять. <code>POST</code> с постоянным внешним идентификатором можно повторять только если партнёр документировал дедупликацию по этому идентификатору и приложение сохраняет один и тот же ключ на все попытки. После timeout с неизвестным состоянием безопасный путь часто состоит из проверки статуса операции, а не из второго создания.'),
|
||
heading('Последовательность проверки'),
|
||
orderedList([
|
||
'Зафиксировать для одного маршрута общий бюджет и короткий connect timeout; указать их рядом с вызовом или в конфигурации.',
|
||
'В тестовой среде отдельно вызвать несуществующий адрес, сервер с задержкой до первого байта и сервер с медленным телом.',
|
||
'После каждого вызова записать cURL error, HTTP-код и накопленные времена NAMELOOKUP, CONNECT, STARTTRANSFER, TOTAL.',
|
||
'Сопоставить рисунок времён с одной стадией, а не менять сразу DNS, timeout и повтор.',
|
||
'Проверить метод и контракт операции до добавления повтора; для создания сущностей описать ключ операции или проверку статуса.',
|
||
'Только затем менять конкретный предел и повторять тот же сценарий на тестовом адресе.',
|
||
]),
|
||
heading('Ограничения разбора'),
|
||
paragraph('Эти поля показывают путь со стороны клиента. Они не раскрывают внутренние очереди партнёра, его базу данных или работу промежуточного прокси. Повторно используемое соединение может сделать connect time маленьким, хотя новый запрос всё равно будет ждать обработчик. При параллельных вызовах или очереди в multi-интерфейсе смысл общего timeout тоже требует отдельной проверки по версии libcurl. Здесь разбирается обычный синхронный PHP-вызов.'),
|
||
heading('Итог'),
|
||
paragraph('Connect timeout, общий timeout и low speed отвечают на три разных наблюдения. Ошибка 28 говорит лишь, что одно из условий сработало. Когда рядом лежат пороги, HTTP-код и временная шкала, запрос перестаёт быть «зависшим вообще»: можно назвать стадию, проверить конкретную границу и не повторить небезопасную операцию.'),
|
||
],
|
||
[
|
||
sources.connectTimeout,
|
||
sources.timeout,
|
||
sources.lowSpeedLimit,
|
||
sources.lowSpeedTime,
|
||
sources.errors,
|
||
sources.getinfo,
|
||
sources.rfc7231,
|
||
sources.rfc9110,
|
||
],
|
||
);
|
||
|
||
const fieldArticle = createRevision(
|
||
{
|
||
slug: 'editorial-2018-07-field-http-timeouts',
|
||
title: 'PHP cURL. Как разобрать зависшую интеграцию по стадиям запроса',
|
||
categories: ['PHP', 'cURL', 'Практика'],
|
||
cover: '/assets/editorial/2018/http-timeout-investigation-2018.svg',
|
||
excerpt: 'Полевой разбор начинается не с повтора запроса: сохраняем error, HTTP-код и стадии cURL, отличаем отсутствие соединения от позднего первого байта и решаем судьбу неопределённой операции.',
|
||
readingMinutes: 12,
|
||
},
|
||
[
|
||
paragraph('Ночная синхронизация сообщает только «таймаут партнёра», а утром неизвестно: DNS не ответил, TLS не установился, партнёр не начал ответ или тело выгрузки шло слишком долго. Если в такой момент запустить задачу повторно, можно одновременно нагрузить недоступный узел и дважды отправить изменение. Цена ошибки — не только сорванная выгрузка, но и неизвестное состояние данных.'),
|
||
paragraph('Ниже учебный полевой маршрут для PHP 2018: как добавить к одному cURL-вызову наблюдаемые стадии, воспроизвести три типа задержки на локальном стенде и принять решение без догадки. Числа и строки лога здесь являются форматом примера, а не заявлением о результатах чужого сервиса.'),
|
||
heading('Сначала фиксирую то, что клиент действительно видел'),
|
||
paragraph('В журнал нельзя писать только фразу «curl timeout». Нужны как минимум метод, безопасный идентификатор операции, cURL error, HTTP-код, пороги клиента и временные отметки. HTTP-код ноль означает, что cURL не получил HTTP-статус от целевого сервера; ненулевой код отделяет транспортную проблему от ответа, который успел сформироваться. Полный URL с токеном, тело запроса и ответ партнёра в общий лог не кладём.'),
|
||
paragraph('Для одной операции нужен постоянный идентификатор. Он помогает сопоставить клиентский лог с логом партнёра и не должен быть случайно создан заново при повторе. Это ещё не идемпотентность: ключ становится защитой только тогда, когда API партнёра принимает его и документированно связывает с одной операцией.'),
|
||
figure(
|
||
'/assets/editorial/2018/http-timeout-investigation-2018.svg',
|
||
'Дерево разбора HTTP-вызова: нет HTTP-кода, поздний первый байт или медленное тело ответа приводят к разным проверкам.',
|
||
'Временные отметки ограничивают место поиска. Повтор не выполняется автоматически для операции с неизвестным состоянием.',
|
||
),
|
||
heading('Собираю одну строку диагностики после curl_exec'),
|
||
paragraph('Код ниже рассчитан на обычный PHP cURL. Сначала завершается <code>curl_exec</code>, затем до <code>curl_close</code> читаются error и сведения о переносе. Время <code>start_transfer</code> означает момент первого полученного байта, а не момент, когда JSON уже разобран приложением. Если API возвращает большой ответ, обработка JSON и запись в базу находятся уже за пределами этой шкалы и их измеряют отдельно.'),
|
||
codeBlock([
|
||
'<?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 в свой журнал с маскировкой чувствительных полей.',
|
||
].join('\n')),
|
||
paragraph('Лог удобнее хранить как поля, а не как склеенную строку. Тогда можно отфильтровать все события с HTTP-кодом ноль и увидеть, какие из них упёрлись в connect limit. Но даже без отдельной системы метрик эти поля дают материал для ручного разбора нескольких случаев. Главное — сохранять их и при успехе, и при ошибке: иначе невозможно сравнить нормальный путь с проблемным.'),
|
||
heading('Делаю задержки воспроизводимыми на локальном стенде'),
|
||
paragraph('Нельзя ждать настоящего сбоя партнёра, чтобы проверить обработчик. В каталоге для теста можно запустить встроенный сервер PHP и создать два маршрута: один задерживает первый байт, другой пишет начало ответа, затем ждёт перед хвостом. Это не модель всего интернета; она нужна, чтобы увидеть разницу между <code>start_transfer</code> и <code>total</code> своим клиентом.'),
|
||
codeBlock([
|
||
'<?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));',
|
||
].join('\n')),
|
||
paragraph('Запуск <code>php -S 127.0.0.1:8080 router.php</code> даёт адрес для клиента из предыдущего раздела. Для <code>/slow-first-byte</code> общий предел меньше четырёх секунд должен остановить вызов до ответа. Для <code>/slow-body</code> первый байт может появиться рано, а общий предел — позже. Поведение <code>flush()</code> зависит от SAPI и прокси, поэтому этот маршрут проверяют именно на своём локальном запуске, а не используют как доказательство поведения production-прокси.'),
|
||
heading('Читаю временную шкалу как стадии, а не как независимые числа'),
|
||
paragraph('Все времена cURL накопительные. Нельзя сложить <code>name_lookup</code>, <code>connect</code> и <code>start_transfer</code>: каждый отсчитывается от начала. Для приближённой длительности DNS смотрят name lookup. Для пути от DNS до TCP и TLS сравнивают более позднюю отметку с name lookup. Для ожидания приложения после установленного соединения сопоставляют start transfer с connect или app connect.'),
|
||
dataTable(
|
||
['Наблюдение в trace', 'Что клиент может утверждать', 'Что не следует утверждать', 'Следующий шаг'],
|
||
[
|
||
['HTTP-код 0, connect близок к лимиту', 'Клиент не получил HTTP-ответ и долго устанавливал соединение', 'Что SQL партнёра медленный', 'Проверить DNS, маршрут, доступность и TLS'],
|
||
['connect мал, start_transfer близок к total', 'Связь установилась, но первый байт не пришёл вовремя', 'Что тело ответа слишком большое', 'Передать партнёру ID операции и его время ожидания'],
|
||
['start_transfer мал, total близок к total limit', 'Ответ начался, но перенос не завершился в бюджете', 'Что проблема именно в DNS', 'Проверить размер, буферизацию и скорость тела'],
|
||
['HTTP-код 500 или 429', 'Сервер успел ответить статусом', 'Что это транспортный timeout', 'Обработать статус по контракту API и его условиям повторов'],
|
||
],
|
||
),
|
||
paragraph('В HTTPS <code>app_connect</code> помогает отделить завершение TLS от последующего ожидания ответа. На HTTP это поле может быть нулём. Нулевое поле нельзя интерпретировать как «TLS занял ноль секунд» без знания схемы URL. В trace полезно положить также схему и безопасно нормализованный host, но не секретные параметры запроса.'),
|
||
heading('Разделяю проверку с партнёром и изменение клиента'),
|
||
paragraph('Когда trace указывает на поздний первый байт, партнёру передают ID операции, время старта, URL-путь, пороги и фактические накопленные времена. Фраза «у вас тормозит» не помогает найти запрос. Когда проблема до соединения, сначала проверяют адрес, DNS и сертификаты со стороны клиента. Когда задерживается тело, сравнивают ожидаемый размер ответа с тем, что реально нужно экрану: возможно, вместо большого списка нужен фильтр или отдельная выгрузка.'),
|
||
paragraph('До любого увеличения timeout сначала повторяют один и тот же тестовый сценарий и смотрят, изменилась ли именно нужная стадия. Если общий предел подняли, а start transfer всё так же приходит поздно, клиент просто дольше скрывает внешний сбой. Если уменьшили объём ответа и total стал меньше при таком же start transfer, улучшили передачу, но не обработчик партнёра.'),
|
||
heading('Не запускаю повтор для неизвестного изменения'),
|
||
paragraph('После timeout у <code>POST</code> клиент не знает, создал ли партнёр заявку до обрыва ответа. HTTP-стандарт различает идемпотентные методы потому, что одинаковый запрос с таким эффектом можно повторять после сбоя связи до чтения ответа. Но даже в 2018 году это не было разрешением считать любой вызов безопасным: бизнес-операция и её контракт важнее удобства очередного запуска.'),
|
||
paragraph('Практическая развилка короткая. GET и HEAD можно повторить ограниченное число раз, если хватает общего бюджета. Изменение состояния повторяют только с постоянным ключом операции и документированной дедупликацией у партнёра или после запроса статуса по уже сохранённому ключу. Если ни одного условия нет, запись помечают как неопределённую и разбирают её отдельно. Так медленный ответ не превращается в два одинаковых действия.'),
|
||
codeBlock([
|
||
'<?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";',
|
||
'}',
|
||
].join('\n')),
|
||
heading('Порядок полевого разбора'),
|
||
orderedList([
|
||
'Сохранить один trace с error, HTTP-кодом, порогами и накопленными временами; исключить из него токены, тело и персональные данные.',
|
||
'Определить, есть ли HTTP-код и на какой отметке остановился путь: до соединения, до первого байта или после начала тела.',
|
||
'Воспроизвести соответствующую задержку на локальном стенде, чтобы проверить, что клиент различает минимум два случая.',
|
||
'Проверить одну внешнюю границу: DNS и TLS, ожидание обработчика партнёра либо объём и скорость ответа.',
|
||
'Для изменения состояния не повторять вызов до проверки постоянного ключа операции и доступности запроса статуса.',
|
||
'Изменить один предел или контракт ответа, снова снять trace и сравнить с исходным по той же стадии.',
|
||
]),
|
||
heading('Ограничения метода'),
|
||
paragraph('Trace с клиента не заменяет логи партнёра и не доказывает, где внутри его сервиса возникла задержка. Он также не показывает время работы PHP до вызова cURL или после разбора ответа. Встроенный сервер PHP годится только для учебной задержки; реальный балансировщик и буферизация могут менять момент первого байта. Если интеграция выполняется в очереди, её собственное время ожидания и число повторов нужно учитывать отдельно от HTTP-клиента.'),
|
||
heading('Итог'),
|
||
paragraph('Полевой разбор зависшей интеграции начинается с одной наблюдаемой строки: error, HTTP-код, пороги и стадии cURL. По ней отделяют отсутствие соединения от позднего первого байта и медленного тела. После этого меняют одну границу или проверяют конкретный узел. Повтор операции остаётся отдельным решением, зависящим от её семантики и известного состояния, а не от того, что cURL вернул timeout.'),
|
||
],
|
||
[
|
||
sources.connectTimeout,
|
||
sources.timeout,
|
||
sources.lowSpeedLimit,
|
||
sources.lowSpeedTime,
|
||
sources.errors,
|
||
sources.getinfo,
|
||
sources.rfc7231,
|
||
sources.rfc9110,
|
||
],
|
||
);
|
||
|
||
export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
|
||
|
||
const isDirectExecution = Boolean(process.argv[1])
|
||
&& path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||
|
||
if (isDirectExecution && process.argv.includes('--print-revisions')) {
|
||
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
|
||
}
|