Files
progcode/web/scripts/upgrade-2027-07.mjs
T
2026-07-31 22:26:56 +03:00

335 lines
38 KiB
JavaScript
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.
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
const p = (text) => '<p>' + text + '</p>';
const h2 = (text) => '<h2>' + text + '</h2>';
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + escapeHtml(alt) + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((cell) => '<th scope="col">' + cell + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
function cloneFixed(value) {
return JSON.parse(JSON.stringify(value));
}
function deepFreeze(value) {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
Object.values(value).forEach(deepFreeze);
Object.freeze(value);
}
return value;
}
function plainText(html) {
return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim();
}
function bodyText(html) {
return plainText(html.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
const REFERENCES = deepFreeze({
http: { title: 'RFC 9110 — HTTP Semantics', version: 'IETF Standards Track, June 2022', url: 'https://www.rfc-editor.org/rfc/rfc9110.html' },
quic: { title: 'RFC 9000 — QUIC: A UDP-Based Multiplexed and Secure Transport', version: 'IETF Standards Track, May 2021', url: 'https://www.rfc-editor.org/rfc/rfc9000.html' },
});
function sources(entries) {
return '<ul>' + entries.map(({ key, use, boundary }) => {
const ref = REFERENCES[key];
return '<li><a href="' + ref.url + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(ref.title) + '</a> — ' + escapeHtml(ref.version) + '. ' + escapeHtml(use) + ' Граница применимости: ' + escapeHtml(boundary) + '</li>';
}).join('') + '</ul>';
}
const FIXED_RELIABILITY_CASES = deepFreeze({
get503: { method: 'GET', status: 503, retryAfter: 1 },
get200: { method: 'GET', status: 200, retryAfter: 0 },
post503: { method: 'POST', status: 503, retryAfter: 1 },
timeout: { method: 'GET', status: 0, error: 'timeout' },
});
export function createFixedReliabilityCase(id = 'get503') {
return FIXED_RELIABILITY_CASES[id] ? deepFreeze(cloneFixed(FIXED_RELIABILITY_CASES[id])) : undefined;
}
export function assessFixedReliabilityPlan(input) {
if (!input || typeof input !== 'object') return { status: 'stop', action: 'проверить входные поля' };
if (input.error === 'timeout') return { status: 'bounded-timeout', action: 'закончить попытку по deadline' };
if (input.status >= 200 && input.status < 300) return { status: 'success', action: 'вернуть результат вызывающему коду' };
if (input.status === 503 && input.method === 'GET') return { status: 'retry-allowed', action: 'повторить с лимитом и задержкой' };
if (input.status === 503) return { status: 'retry-denied', action: 'проверить идемпотентность операции' };
return { status: 'stop', action: 'разобрать ответ отдельно' };
}
export function inspectFailureContractLiteral() {
return deepFreeze({ timeoutMs: 400, maxAttempts: 3, retryableMethods: ['GET', 'HEAD', 'PUT', 'DELETE'] });
}
export function inspectFaultTreeLiteral() {
return deepFreeze({ first: 'timeout', second: '503', decision: 'bounded-retry' });
}
export function inspectHandoffLiteral() {
return deepFreeze({ attempt: 2, method: 'GET', status: 200, next: 'return-response' });
}
export function runFixedReliabilityFixture() {
const checks = [
['get503', 'retry-allowed'],
['get200', 'success'],
['post503', 'retry-denied'],
['timeout', 'bounded-timeout'],
].map(([id, expected]) => ({ id, expected, actual: assessFixedReliabilityPlan(createFixedReliabilityCase(id)).status }));
return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual), checks });
}
function revision(meta, parts, referenceEntries) {
const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength);
return deepFreeze({ ...meta, contentHtml, proseLength });
}
const refs = [
{ key: 'http', use: 'Задаёт семантику безопасных и идемпотентных методов, статусов и поля Retry-After.', boundary: 'Не выбирает политику повтора для конкретного API и не гарантирует отсутствие побочных эффектов.' },
{ key: 'quic', use: 'Показывает, что транспортное управление потоками и потерями отделено от прикладной семантики запроса.', boundary: 'Не является инструкцией по retry HTTP-клиента и не описывает поведение конкретной библиотеки.' },
];
const practice = revision({
slug: 'editorial-2027-07-practice-reliability-capstone',
title: 'Повтор запроса без двойного действия: timeout, 503 и безопасный retry',
categories: ['Надёжность', 'HTTP'],
cover: '/assets/editorial/2027/reliability-capstone-2027-fault-tree-contract.svg',
excerpt: 'Практический клиент с общим deadline и повтором только там, где операция допускает повторное выполнение.',
readingMinutes: 14,
}, [
p('Клиент получил timeout и повторил запрос, а пользователь увидел две созданные заявки. Цена ошибки — повторить побочный эффект, который сервер уже выполнил, но ответ потерялся по дороге. Обратная ситуация тоже опасна: не повторить безопасный <code>GET</code> после временного <code>503</code> и показать лишний сбой.'),
p('Нужны три явных ограничения: общий deadline, число попыток и список методов, для которых повтор допустим. Учебный локальный сервер дважды отвечает <code>503</code>, затем возвращает <code>200</code>. Клиент показывает порядок попыток и завершает работу в пределах заданного времени.'),
h2('Сначала решаем, что можно повторить'),
p('RFC 9110 называет идемпотентным запрос, повтор которого имеет тот же ожидаемый эффект, даже если отдельные ответы отличаются. Это свойство операции, а не магия клиента: <code>GET</code>, <code>HEAD</code>, <code>PUT</code> и <code>DELETE</code> имеют другую семантику, чем обычный <code>POST</code>. Сервер может отдельно предоставить идемпотency-key для создания ресурса, но это нужно подтвердить его контрактом.'),
table('Матрица решения о повторе', ['Метод', 'Пример действия', 'Повтор после timeout', 'Условие'], [
['GET', 'прочитать каталог', 'обычно допустим', 'лимит и deadline'],
['HEAD', 'проверить ресурс', 'обычно допустим', 'ответ не нужен в теле'],
['PUT', 'записать состояние по ключу', 'возможен', 'сервер сохраняет идемпотентность'],
['DELETE', 'удалить по идентификатору', 'возможен', 'повторный 404 трактуется контрактом'],
['POST', 'создать заказ', 'не по умолчанию', 'idempotency-key и правила сервера'],
]),
figure('/assets/editorial/2027/reliability-capstone-2027-fault-tree-contract.svg', 'Дерево решения для повторения HTTP-запроса: timeout, статус, метод, deadline и конечный ответ.', 'Диаграмма сначала проверяет deadline и метод, затем статус. Ошибка не превращается в повтор автоматически.'),
h2('Общий deadline важнее числа попыток'),
p('Три попытки по 500 миллисекунд могут занять больше секунды, если между ними стоят задержки. Пользователь и вызывающая система ждут не количество попыток, а завершение операции. Поэтому задаём общий deadline, а на каждой итерации считаем оставшееся время. Если его недостаточно для следующего запроса, возвращаем timeout.'),
p('Задержка между попытками должна быть ограниченной и лучше иметь случайное рассеивание в многоклиентской системе. Но jitter не исправляет неправильную семантику. Сначала решается «можно ли повторять», затем «сколько времени отдать», и только потом выбираются backoff и случайная добавка.'),
h2('Учебный клиент и локальный сервер'),
p('Вход функции <code>requestWithRetry</code> — URL, метод и параметры времени. Сервер хранит счётчик только внутри процесса: первые два ответа — 503, третий — 200. Ожидаемый результат — <code>attempts: 3</code> и <code>status: 200</code>. На локальном loopback нет внешней зависимости.'),
code([
"import { createServer } from 'node:http';",
'',
'let calls = 0;',
"const server = createServer((request, response) => {",
' calls += 1;',
' if (calls < 3) {',
" response.writeHead(503, { 'retry-after': '0' });",
" response.end('busy');",
' return;',
' }',
' response.end(\'ready\');',
'});',
'',
'async function requestWithRetry(url, { method = \'GET\', maxAttempts = 3, deadlineMs = 1000 } = {}) {',
" if (!['GET', 'HEAD', 'PUT', 'DELETE'].includes(method)) throw new Error('method is not retryable');",
' const deadline = Date.now() + deadlineMs;',
' for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {',
' const remaining = deadline - Date.now();',
" if (remaining <= 0) throw new Error('deadline reached');",
' const response = await fetch(url, { method, signal: AbortSignal.timeout(remaining) });',
" if (response.ok) return { attempts: attempt, status: response.status };",
' }',
' throw new Error(\'deadline reached\');',
'}',
'',
"server.listen(0, '127.0.0.1', async function retryDemo() {",
' const port = server.address().port;',
" console.log(await requestWithRetry('http://127.0.0.1:' + port));",
' server.close();',
'});',
].join('\n')),
p('Результат — <code>{ attempts: 3, status: 200 }</code>. В коде есть намеренное ограничение: он не повторяет POST и не разбирает Retry-After. Для учебного стенда этого достаточно, чтобы увидеть связь между методом и повтором. В библиотеке нужно добавить AbortSignal, deadline, backoff, обработку сетевого исключения и лог безопасных попыток.'),
h2('Что означает 503'),
p('503 — ответ сервера, а не доказательство, что повтор сработает. Заголовок <code>Retry-After</code> может указать время ожидания, но его значение нужно проверить как число секунд или дату. Если сервис за прокси возвращает 503 от посредника, повтор может увеличить нагрузку на origin. Логируйте источник ответа, если архитектура позволяет отличить proxy от приложения.'),
p('Для timeout сложнее: запрос мог быть принят и выполнен, а клиент потерял ответ. Для чтения это обычно приемлемо, для записи — нет без ключа дедупликации. Поэтому error class должна хранить не только «сетевой сбой», но и метод, request id, idempotency key и достигнутый этап.'),
h2('Порядок внедрения'),
ol([
'Составить таблицу методов и побочных эффектов конкретного API.',
'Ввести общий deadline и передавать его во все сетевые вызовы.',
'Разрешить retry только для подтверждённых безопасных или идемпотентных операций.',
'Ограничить число попыток, задержку и суммарное время ожидания.',
'Сохранить попытки, статусы и тип ошибки без токенов и тела с персональными данными.',
'Проверить отдельным тестом timeout после принятия записи и ответ 503 от посредника.',
]),
h2('Ограничения и следующий шаг'),
p('Локальный сервер не моделирует потерю ответа после выполнения записи, балансировку и лимиты зависимостей. Список retryable-методов — не универсальная политика: API может ограничить повтор иначе. QUIC управляет транспортом, но не делает прикладной POST идемпотентным.'),
p('Следующий шаг — добавить к API контракт idempotency key для операций создания и проверить его на одинаковом ключе. Для чтения подключите bounded retry с общим deadline. Критерий готовности простой: повтор не выходит за время, не скрывает причину и не создаёт второй побочный эффект.'),
], refs);
const mechanism = revision({
slug: 'editorial-2027-07-mechanism-reliability-capstone',
title: 'Почему retry иногда удваивает данные: идемпотентность на уровне протокола',
categories: ['Надёжность', 'Архитектура'],
cover: '/assets/editorial/2027/reliability-capstone-2027-recovery-evidence-matrix.svg',
excerpt: 'Разбираем границу между транспортной доставкой и эффектом бизнес-операции.',
readingMinutes: 15,
}, [
p('Клиент видит timeout, но сервер мог уже применить команду. Цена автоматического retry — двойная запись, двойная отправка письма или два списания. Удалить повтор из клиента тоже нельзя: для чтения временный сбой должен переживаться. Причина в том, что транспорт сообщает о доставке байтов, а не о том, какой эффект зафиксирован на стороне приложения.'),
p('Разберём эту границу на локальном счётчике и HTTP-методах. Код не имитирует сложную базу: он показывает, что повторяемость — свойство контракта операции, а не только сетевого соединения. После примера отделим idempotency key от простого request id и назовём ограничения.'),
h2('Транспорт не знает бизнес-эффекта'),
p('TCP или QUIC могут доставить поток, обнаружить потерю и закрыть соединение. RFC 9000 описывает потоки, контроль потока и состояния соединения, но полезная нагрузка остаётся данными приложения. Клиент может получить исключение после отправки последнего байта. Из этого нельзя вывести, была ли транзакция применена.'),
table('Три разных идентификатора', ['Идентификатор', 'Кто создаёт', 'Задача'], [
['connection id', 'транспорт', 'сопоставить пакеты соединению'],
['request id', 'клиент или входной сервис', 'связать лог одной попытки'],
['idempotency key', 'клиент для операции', 'узнать повтор того же действия'],
['resource id', 'доменная модель', 'какой объект изменяется'],
]),
figure('/assets/editorial/2027/reliability-capstone-2027-recovery-evidence-matrix.svg', 'Матрица границ надёжности: транспорт, HTTP-метод, ключ операции, запись и подтверждение результата.', 'Матрица не утверждает доставку конкретного запроса. Она показывает, какой слой отвечает на какой вопрос.'),
h2('Идемпотентность — не «повтор без ошибок»'),
p('Идемпотентность означает, что несколько одинаковых запросов имеют тот же ожидаемый эффект, что и один. Ответы могут различаться: первый <code>200</code>, второй <code>204</code> или <code>404</code> в зависимости от контракта. Поэтому тест проверяет состояние и правила сервера, а не только код ответа.'),
p('Для POST дедупликация часто строится на idempotency key. Сервер сохраняет результат по ключу и возвращает его при повторе, не создавая новый объект. Ключ должен быть привязан к смысловой операции и иметь срок хранения, иначе одинаковая строка через месяц случайно подавит новую команду. Этот срок и область уникальности являются частью API-контракта.'),
h2('Учебный сервер с защитой от дубля'),
p('Код ниже принимает только локальные POST-запросы с заголовком <code>Idempotency-Key</code>. Вход — ключ и JSON-тело, но учебный сервер читает только ключ: это специально оставленная граница примера. Ожидаемый результат — два ответа с одним номером записи: второй запрос возвращает сохранённый результат. Это runnable пример поведения endpoint, а не готовая замена транзакции или базы данных.'),
code([
"import { createServer } from 'node:http';",
'',
'const results = new Map();',
'let nextId = 1;',
'',
"const server = createServer((request, response) => {",
" if (request.method !== 'POST') { response.writeHead(405); response.end(); return; }",
" const key = request.headers['idempotency-key'];",
" if (!key) { response.writeHead(400); response.end('key required'); return; }",
' if (!results.has(key)) results.set(key, { id: nextId++, state: \'created\' });',
" response.writeHead(200, { 'content-type': 'application/json' });",
' response.end(JSON.stringify(results.get(key)));',
'});',
'',
"server.listen({ host: '127.0.0.1', port: 0 }, async () => {",
' const endpoint = \'http://127.0.0.1:\' + server.address().port;',
" const options = { method: 'POST', headers: { 'Idempotency-Key': 'order-42' }, body: '{}' };",
' console.log(await (await fetch(endpoint, options)).json());',
' console.log(await (await fetch(endpoint, options)).json());',
' server.close();',
'});',
].join('\n')),
p('Обе строки имеют один и тот же <code>id</code>. В примере Map живёт в памяти процесса, поэтому перезапуск сервера уничтожит историю. В настоящем сервисе ключ должен проверяться атомарно рядом с записью результата, а тело и параметры операции должны входить в проверку совпадения. Один заголовок без серверной дедупликации ничего не гарантирует.'),
h2('Где начинается непредсказуемость'),
p('Если ключ повторно прислали с другим телом, сервер должен отклонить запрос или явно выбрать правило. Молча вернуть старый результат опасно: клиент может подумать, что новый адрес уже сохранён. Если срок хранения закончился, повтор того же ключ становится новой операцией. Эти варианты должны быть в контракте, тестах и журналах.'),
p('Request id не заменяет idempotency key. Он различает попытки, но каждый retry обычно получает новый request id. Ключ операции остаётся прежним и связывает попытки в одну семантическую команду. В логах полезно хранить оба поля, а также номер попытки и итоговое состояние.'),
h2('Как выбирать поведение'),
table('Вопрос до автоматического retry', ['Вопрос', 'Да', 'Нет'], [
['Операция безопасна или идемпотентна?', 'retry можно рассматривать', 'остановиться и проверить контракт'],
['Есть общий deadline?', 'ограничить все попытки', 'ввести deadline'],
['Сервер дедуплицирует ключ?', 'повторить с тем же key', 'не повторять побочный эффект'],
['Ответ точно связан с записью?', 'сохранить result', 'проверить состояние отдельно'],
]),
h2('Порядок проверки контракта'),
ol([
'Назвать доменное действие и его побочный эффект, не ограничиваясь HTTP-методом.',
'Проверить правило повторения в RFC-контракте API и коде сервера.',
'Для создания определить формат, срок и область уникальности idempotency key.',
'Сделать тест «ответ потерян после записи» и проверить, что повтор возвращает тот же результат.',
'Сохранить request id, operation key и номер попытки в структурированном логе.',
'Описать ответ на несовпадающее тело и истёкший ключ.',
]),
h2('Ограничения и следующий шаг'),
p('Map в примере не даёт атомарности при нескольких процессах, не переживает рестарт и не защищает от бесконечного роста. QUIC не решает дедупликацию, а HTTP-метод не раскрывает внутреннюю транзакцию. Для платежей и других критичных действий нужен отдельный контракт, тест отказа и согласованное хранилище результата.'),
p('Следующим шагом добавьте к одному endpoint тест с повтором одного ключа и другим телом. Ожидаемый результат должен быть явным: конфликт или тот же ответ, но не новая запись. После этого только выбирайте retry policy для клиента.'),
], refs);
const field = revision({
slug: 'editorial-2027-07-field-reliability-capstone',
title: 'Лог повторов, который помогает расследовать сбой: attempt, deadline и причина',
categories: ['Надёжность', 'Наблюдаемость'],
cover: '/assets/editorial/2027/reliability-capstone-2027-response-handoff-loop.svg',
excerpt: 'Какие поля сохранить, чтобы отделить временный сбой от исчерпанного времени и опасного повтора.',
readingMinutes: 14,
}, [
p('В журнале часто остаётся только «request failed». Цена такой записи — не понять, был ли это timeout первой попытки, ответ 503 второй или отказ от повтора операции записи. Без номера попытки и общего deadline команда увеличивает retry, не видя, что каждый новый запрос уже съедает остаток времени.'),
p('Соберём компактное событие попытки и локальный исполнитель, который возвращает результат или ошибку с причиной. Входы фиксированы: метод, endpoint без секретов, лимит попыток и deadline. Выход — список событий и итог. Пример учебный и не подключается к журналу, сети или системе наблюдаемости.'),
h2('Одно событие — одна попытка'),
p('Не смешивайте в одну строку попытку и операцию. Операция имеет устойчивый идентификатор, попытка — порядковый номер, начало, длительность, статус и причину. <code>deadlineRemainingMs</code> показывает, сколько времени оставалось перед вызовом. Вместе эти поля позволяют увидеть, где именно закончился бюджет времени.'),
table('Минимальные поля retry-события', ['Поле', 'Пример', 'Зачем'], [
['operationId', 'op-42', 'связать попытки одного действия'],
['attempt', '2', 'видеть число вызовов'],
['method', 'GET', 'проверить семантику повтора'],
['status', '503 или timeout', 'отличить ответ от исключения'],
['remainingMs', '180', 'увидеть границу времени'],
['decision', 'retry или return', 'зафиксировать действие клиента'],
]),
figure('/assets/editorial/2027/reliability-capstone-2027-response-handoff-loop.svg', 'Цикл обработки ответа: попытка, фиксация статуса, проверка deadline, решение о повторе или возврате.', 'Цикл сохраняет событие до следующего вызова. Конечный ответ и исчерпанное время становятся различимыми состояниями.'),
h2('Учебный исполнитель с фиксированными ответами'),
p('Функция принимает массив заранее заданных результатов. Вход не содержит внешних данных: это позволяет проверить порядок событий и границу попыток. Ожидаемый результат — после двух 503 возвращается 200, а в событиях остаются номера 1, 2 и 3. В реальном клиенте массив заменяется сетевым вызовом, а формат события сохраняется.'),
code([
'function runBoundedRetries({ operationId, method, responses, maxAttempts = 3, deadlineMs = 400 }) {',
' const events = [];',
' for (let index = 0; index < Math.min(maxAttempts, responses.length); index += 1) {',
' const result = responses[index];',
' const retryable = method === \'GET\' && result.status === 503;',
' const remainingMs = Math.max(0, deadlineMs - index * 120);',
' events.push({ operationId, attempt: index + 1, method, status: result.status, remainingMs, decision: retryable ? \'retry\' : \'return\' });',
' if (remainingMs === 0) return { result: { status: \'deadline\' }, events };',
' if (!retryable) return { result, events };',
' }',
' return { result: { status: \'deadline\' }, events };',
'}',
'',
"console.log(runBoundedRetries({ operationId: 'op-42', method: 'GET', responses: [{ status: 503 }, { status: 503 }, { status: 200 }] }));",
].join('\n')),
p('В выводе три события, а итоговый статус — 200. Если заменить метод на <code>POST</code>, первая запись сразу получит решение <code>return</code>. Это не утверждение о любом POST: исполнитель демонстрирует консервативную политику, которую нужно заменить контрактом конкретного endpoint. Название <code>decision</code> полезнее, чем свободная фраза в логе.'),
h2('Причина и действие должны быть разными полями'),
p('Причина — timeout, 503, 429, DNS error или отмена. Действие — retry, return, fail или cancel. Если записать «retry из-за временной ошибки» одной строкой, невозможно посчитать, какой класс ответов создал нагрузку. Раздельные поля позволяют построить таблицу по методу и не смешивать сетевой отказ с бизнес-ошибкой.'),
p('Не сохраняйте тело ответа по умолчанию. Для диагностики обычно достаточно status, безопасного подтипа и размера. URL нормализуйте, удаляя query-секреты. Идентификатор операции не должен быть email, номером карты или токеном. Чем больше произвольного текста в событии, тем выше стоимость хранения и риск утечки.'),
h2('Число попыток не равно надёжности'),
table('Что можно увидеть в событиях', ['Набор', 'Интерпретация', 'Действие'], [
['1 timeout, return', 'deadline слишком мал или вызов завис', 'разделить connect/read timeout'],
['3 × 503, deadline', 'ответ не восстановился за время', 'проверить зависимость и backoff'],
['1 × 429, return', 'сработал rate limit', 'прочитать Retry-After и снизить темп'],
['POST, 503, return', 'повтор запрещён политикой', 'проверить состояние по operation key'],
]),
h2('Исчерпанное время — отдельный результат'),
p('Если последняя попытка закончилась на deadline, это не то же самое, что ответ 503. Сервер мог принять запрос, а клиент не успел дождаться ответа. В событии сохраняйте <code>lastAttempt</code> и <code>lastKnownStatus</code>, но не превращайте неизвестное состояние в «операция не выполнена». Для чтения можно вернуть контролируемую ошибку, для записи — запросить состояние по ключу операции.'),
p('Такой вывод меняет следующий шаг. Для timeout чтения проверяем границы времени и зависимость. Для timeout записи сначала ищем безопасный способ узнать состояние, а не запускаем ещё один POST. Небольшая разница в двух полях предотвращает самый дорогой вид автоматизма — повтор действия, о результате которого клиент уже не знает.'),
h2('Порядок внедрения события'),
ol([
'Выбрать operationId и правило его жизненного цикла.',
'Добавить attempt, method, status или errorClass и оставшееся время.',
'Разделить decision от причины и ограничить перечисление значений.',
'Удалить query-секреты, cookie, тело и персональные поля до записи.',
'Проверить наборы GET/503, GET/timeout, POST/503 и успешный первый вызов.',
'Считать распределение попыток и долю завершений по deadline, не меняя логику по одному шумному сообщению.',
]),
h2('Ограничения и следующий шаг'),
p('Локальный исполнитель не показывает распределённую доставку логов, часы разных узлов и повтор после потери ответа. Поля события должны быть согласованы между клиентом и сервером, иначе operationId распадётся на несколько имён. Наблюдаемость не делает retry безопасным — она только показывает, что он делает.'),
p('Следующий шаг — подключить эти поля к одному безопасному GET и построить отчёт по p95 попыток и доле timeout. Для записи сначала добавьте operation key и проверку состояния. Только после этого увеличивайте число повторов или задержку.'),
], refs);
export const revisions = deepFreeze([practice, mechanism, field]);
export function verifyRevisionsAgainstFixture() {
const fixture = runFixedReliabilityFixture();
const articleChecks = revisions.map((item) => {
const text = bodyText(item.contentHtml);
return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|затрат|потер)/i.test(text.slice(0, 1100)) && /<table>/.test(item.contentHtml) && /<figure>/.test(item.contentHtml) && /<pre><code>/.test(item.contentHtml) && /<ol>/.test(item.contentHtml);
});
return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) });
}
if (process.argv.includes('--verify-fixture')) {
const result = verifyRevisionsAgainstFixture();
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
if (!result.accepted) process.exitCode = 1;
}
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');