import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function paragraph(text) { return '

' + text + '

'; } function heading(text) { return '

' + text + '

'; } function codeBlock(code) { return '
' + escapeHtml(String(code).trim()) + '
'; } function figure(src, alt, caption) { return '
' + alt + '
' + caption + '
'; } function orderedList(items) { return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; } function dataTable(caption, headers, rows) { const head = '' + headers.map((header) => '' + header + '').join('') + ''; const body = '' + rows.map((row) => '' + row.map((cell) => '' + cell + '').join('') + '').join('') + ''; return '
' + head + body + '
' + caption + '
'; } function sourceList(items) { return ''; } function plainText(content) { return content .replace(/<[^>]+>/g, ' ') .replace(/&(?:quot|amp|lt|gt|#039);/g, ' ') .replace(/\s+/g, ' ') .trim(); } function bodyText(content) { return plainText( content.replace(/

Проверяемые источники<\/h2>[\s\S]*?(?=

|$)/, ''), ); } function createRevision(meta, bodyParts, sources) { if (sources.length < 2) { throw new Error(meta.slug + ': нужно минимум два первичных или официальных источника'); } const contentHtml = bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources); const length = bodyText(contentHtml).length; if (length < 5000 || length > 15000) { throw new Error(meta.slug + ': основной текст вне 5 000–15 000 знаков: ' + length); } return { ...meta, contentHtml, }; } const rfc7231 = { title: 'RFC 7231: HTTP/1.1 Semantics and Content', url: 'https://www.rfc-editor.org/rfc/rfc7231.html', note: 'стандарт HTTP, актуальный в 2020 году: семантика idempotent methods, статусы 409/503 и поле Retry-After; позже заменён RFC 9110', }; const idempotencyDraft2020 = { title: 'IETF draft-idempotency-header-01, январь 2020', url: 'https://datatracker.ietf.org/doc/html/draft-idempotency-header-01', note: 'исторический work in progress, а не утверждённый RFC: описывает ключ, fingerprint, повтор завершённой операции и конфликт параллельного повтора', }; const nodeTimeout = { title: 'Node.js v12: http.ClientRequest.setTimeout', url: 'https://nodejs.org/dist/latest-v12.x/docs/api/http.html#http_request_settimeout_timeout_callback', note: 'официальная документация линии Node 12: таймер request сообщает о бездействии соединения, но сам по себе не отменяет бизнес-эффект на сервере', }; const postgresConstraints = { title: 'PostgreSQL 12: Constraints', url: 'https://www.postgresql.org/docs/12/ddl-constraints.html', note: 'официальное описание уникальных ограничений; уникальность ключа должна проверяться хранилищем, а не только памятью одного процесса', }; const postgresInsert = { title: 'PostgreSQL 12: INSERT и ON CONFLICT', url: 'https://www.postgresql.org/docs/12/sql-insert.html', note: 'официальная семантика INSERT ... ON CONFLICT для атомарного захвата уникальной записи в выбранной базе', }; const retryRequest = [ 'POST /v1/demo-requests HTTP/1.1', 'Host: api.training.invalid', 'Content-Type: application/json', 'Idempotency-Key: demo-key-retry-2020-06-a7f1', 'X-Client-Attempt: 2', '', '{"topic":"bundle review","note":"учебная заявка без персональных и платёжных данных"}', '', 'HTTP/1.1 201 Created', 'Content-Type: application/json', '', '{"requestId":"demo-request-42","state":"accepted"}', ].join('\n'); const clientIntentExample = [ '// Учебный код: transport передаётся извне, реальный endpoint не вызывается.', 'function beginIntent(payload, keyFactory, now) {', ' return {', ' key: keyFactory(),', ' payload: payload,', ' createdAt: now(),', ' deadlineAt: now() + 5000,', ' attempt: 0,', ' state: "pending",', ' };', '}', '', 'async function sendIntent(intent, transport, now) {', ' if (now() >= intent.deadlineAt) {', ' return { kind: "budget_exhausted", key: intent.key };', ' }', '', ' intent.attempt += 1;', ' return transport.post("/v1/demo-requests", intent.payload, {', ' "Idempotency-Key": intent.key,', ' "X-Client-Attempt": String(intent.attempt),', ' });', '}', '', '// При timeout пользовательский intent не получает новый key.', '// Новый key появляется только после нового действия или изменения payload.', ].join('\n'); const requestBudget = [ 'Интерфейсный бюджет: 5000 ms', '├─ попытка 1: ждать ответ не более 1800 ms', '├─ короткая пауза и проверка состояния: 250 ms', '├─ попытка 2 тем же ключом: ждать не более 1800 ms', '└─ 1150 ms: показать неопределённый исход и дать безопасный следующий шаг', ].join('\n'); const reservationSql = [ '-- Учебная схема PostgreSQL 12, не миграция конкретного проекта.', 'CREATE TABLE request_idempotency (', ' actor_id bigint NOT NULL,', ' operation varchar(80) NOT NULL,', ' idem_key varchar(128) NOT NULL,', ' payload_hash char(64) NOT NULL,', ' state varchar(16) NOT NULL,', ' status_code integer,', ' response_body jsonb,', ' created_at timestamp NOT NULL,', ' expires_at timestamp NOT NULL,', ' UNIQUE (actor_id, operation, idem_key)', ');', '', 'INSERT INTO request_idempotency (', ' actor_id, operation, idem_key, payload_hash, state, created_at, expires_at', ') VALUES (', ' :actorId, :operation, :key, :payloadHash, "in_progress", now(), :expiresAt', ')', 'ON CONFLICT (actor_id, operation, idem_key) DO NOTHING', 'RETURNING state, payload_hash;', ].join('\n'); const reservationExample = [ '// Учебный псевдокод. db.transaction определяет границу выбранной БД.', 'async function acceptCreate(db, input) {', ' const slot = await db.transaction(async (tx) => {', ' const inserted = await tx.insertIdempotencyIfAbsent({', ' actorId: input.actorId,', ' operation: "demo-request.create",', ' key: input.key,', ' payloadHash: hashCanonicalPayload(input.payload),', ' });', '', ' if (inserted) return { kind: "owner" };', '', ' const saved = await tx.readIdempotency(input.actorId, "demo-request.create", input.key);', ' if (saved.payloadHash !== hashCanonicalPayload(input.payload)) {', ' return { kind: "conflict" };', ' }', ' if (saved.state === "completed") return { kind: "replay", saved: saved };', ' return { kind: "in_progress" };', ' });', '', ' if (slot.kind !== "owner") return slot;', '', ' const result = await db.transaction(async (tx) => {', ' const created = await createDemoRequest(tx, input.payload);', ' await tx.finishIdempotency(input.actorId, "demo-request.create", input.key, created);', ' return created;', ' });', ' return { kind: "created", result: result };', '}', ].join('\n'); const traceExample = [ '2020-06-18T10:00:00.090Z edge requestId=req-51 route=demo-request key=demo-key-retry-2020-06-a7f1 upstream=sent', '2020-06-18T10:00:00.214Z app requestId=req-51 key=demo-key-retry-2020-06-a7f1 decision=owner state=in_progress', '2020-06-18T10:00:00.341Z app requestId=req-51 key=demo-key-retry-2020-06-a7f1 decision=stored status=201 result=demo-request-42', '2020-06-18T10:00:01.801Z client requestId=req-51 outcome=timeout response=not_observed', '2020-06-18T10:00:02.080Z edge requestId=req-52 route=demo-request key=demo-key-retry-2020-06-a7f1 upstream=sent', '2020-06-18T10:00:02.093Z app requestId=req-52 key=demo-key-retry-2020-06-a7f1 decision=replay status=201 result=demo-request-42', ].join('\n'); function runLostResponseFixture() { const records = new Map(); let effects = 0; function receive(input) { const existing = records.get(input.key); if (existing) { if (existing.payloadHash !== input.payloadHash) { return { kind: 'conflict', status: 409 }; } if (existing.state === 'completed') { return { kind: 'replay', status: existing.status, resultId: existing.resultId }; } return { kind: 'in_progress', status: 409 }; } effects += 1; const completed = { payloadHash: input.payloadHash, resultId: 'demo-request-42', state: 'completed', status: 201, }; records.set(input.key, completed); return input.responseLost ? { kind: 'response_lost', status: null } : { kind: 'created', status: completed.status, resultId: completed.resultId }; } const first = receive({ key: 'demo-key-retry-2020-06-a7f1', payloadHash: 'fixture-payload-v1', responseLost: true, }); const retry = receive({ key: 'demo-key-retry-2020-06-a7f1', payloadHash: 'fixture-payload-v1', responseLost: false, }); const mismatch = receive({ key: 'demo-key-retry-2020-06-a7f1', payloadHash: 'fixture-payload-v2', responseLost: false, }); return { effects, first, mismatch, retry, checks: { firstWasUnknownToClient: first.kind === 'response_lost', onlyOneEffect: effects === 1, retryReplayedStoredResult: retry.kind === 'replay' && retry.resultId === 'demo-request-42', payloadMismatchRejected: mismatch.kind === 'conflict', }, }; } function verifyFixture() { const fixture = runLostResponseFixture(); const ok = Object.values(fixture.checks).every(Boolean); if (!ok) { throw new Error('fixture did not preserve one effect and one replay'); } return fixture; } const practiceArticle = createRevision( { slug: 'editorial-2020-06-practice-retry-idempotency', title: 'Повтор HTTP-запроса без второго эффекта: ключ, результат и бюджет', categories: ['HTTP', 'Frontend', 'Backend'], cover: '/assets/editorial/2020/retry-idempotency-request-path-2020.svg', excerpt: 'Потерянный ответ не доказывает, что сервер ничего не сделал. Разбираем один пользовательский intent, idempotency key, ограниченный retry и контракт результата.', readingMinutes: 14, }, [ paragraph('Симптом начинается с обычной кнопки «отправить»: интерфейс ждёт ответ, по локальному timeout показывает ошибку, а пользователь нажимает ещё раз. На сервере первая операция могла уже создать заявку, файл или запись, просто ответ не вернулся к браузеру. Цена — двойной эффект, спорный статус для пользователя и ручная чистка того, что код уже не умеет связать с первым нажатием. Отключённая кнопка снижает число кликов, но не защищает от обновления страницы, повторной отправки формы или автоматического retry транспорта.'), paragraph('Для июньского материала 2020 года возьму учебную операцию создания внутренней заявки на разбор bundle. У неё нет платёжных реквизитов, реального домена и production endpoint. Зато есть граница, общая для frontend, delivery и backend: клиент повторяет не HTTP-байты, а один пользовательский intent; сервис хранит итог этого intent; timeout получает конечный бюджет. Цель не в том, чтобы повторять каждый сбой, а в том, чтобы после неопределённого исхода не создавать второй эффект.'), heading('Сначала называем один intent, а не одну попытку'), paragraph('Intent появляется, когда пользователь подтвердил конкретный набор полей. В этот момент клиент получает ключ идемпотентности и держит его рядом с payload до terminal результата: принятой заявки, понятной ошибки валидации или сознательной отмены. Первая отправка и безопасный повтор несут одинаковый ключ. Если пользователь изменил тему или содержание заявки, это уже другой intent: старый ключ закрывают, новому payload дают новый ключ. Повторно использовать ключ для другого тела нельзя, иначе сервер не различит retry и две разные команды.'), paragraph('Ключ не должен быть номером пользователя, порядковым номером формы или значением, которое легко угадать. В статье функция генерации оставлена за контрактом проекта: учебное значение нужно только для чтения следа. В IETF-драфте начала 2020 года ключ описан как значение, по которому resource узнаёт повтор, но это был work in progress, а не причина считать любой заголовок универсально поддержанным. Если команда выбирает имя Idempotency-Key, она документирует scope, срок хранения, правило для изменённого payload и ответ на параллельный повтор.'), figure('/assets/editorial/2020/retry-idempotency-request-path-2020.svg', 'Вертикальная схема пути одной учебной заявки: пользовательский intent получает один ключ, первая попытка доходит до сервиса и сохраняет результат, ответ теряется, а вторая попытка с тем же ключом получает сохранённый результат без нового эффекта', 'Timeout сообщает клиенту, что ответ не наблюдался. Он не даёт права считать серверную операцию отменённой; повтор остаётся безопасным только при общем ключе и сохранённом результате.'), heading('Контракт клиента: что остаётся неизменным'), dataTable( 'Состояние одной учебной заявки на стороне клиента', ['Элемент', 'На первой попытке', 'На retry', 'Когда меняется'], [ ['Payload', 'сериализован из подтверждённой формы', 'тот же набор значимых полей', 'только после нового действия пользователя'], ['Idempotency key', 'создан для intent', 'передан без замены', 'при новом intent, а не при timeout'], ['Номер попытки', '1 для журнала', 'увеличивается', 'не участвует в идентичности эффекта'], ['Дедлайн intent', 'отсчитывается один раз', 'сокращает доступное время', 'не продлевается бесконечно каждым retry'], ['Отображаемый статус', 'ожидание ответа', 'неопределённый исход или повтор', 'после терминального ответа сервиса'], ], ), paragraph('Таблица отделяет два похожих поля. Номер попытки полезен для журнала и UI, но не может быть частью ключа: тогда вторая попытка станет для backend новой командой. Дедлайн тоже принадлежит intent, а не одному сетевому вызову. Если при каждом timeout запускать новый пятисекундный таймер, страница может держать запросы и повторять их дольше, чем пользователь готов ждать. В результате возникает всплеск нагрузки именно в момент, когда зависимость уже отвечает плохо.'), heading('Минимальный HTTP-след без настоящего endpoint'), paragraph('В примере ниже адрес с зоной .invalid и значения намеренно вымышлены. Две отправки одного payload должны дать один и тот же сохранённый результат. Сервис вправе вернуть код и тело первого завершённого ответа ещё раз; он не обязан показывать клиенту внутреннюю запись deduplication. Если первый запрос ещё обрабатывается, контракт должен сообщить отдельный конфликт или статус ожидания, а не запускать обработчик параллельно.'), codeBlock(retryRequest), paragraph('Метод POST сам по себе не становится идемпотентным из-за имени header. RFC 7231 различает семантику методов: повторить idempotent method клиент может при сбое связи, но для POST проект обязан отдельно описать эффект. Поэтому в ревью API недостаточно увидеть заголовок. Нужно задать четыре вопроса: на каком scope ключ уникален, какая часть payload сравнивается, сколько живёт запись и какой именно ответ получает дубль. Без этих ответов retry остаётся надеждой на порядок сетевых пакетов.'), heading('Ключ создаётся до первого вызова и переживает timeout'), paragraph('Код не привязан к конкретному fetch-клиенту. Он показывает только порядок владения состоянием: intent создан один раз, deadline тоже задан один раз, каждая попытка передаёт те же key и payload. Transport обязан сообщить, был ли получен HTTP-ответ или возник транспортный сбой. Он не должен сам незаметно генерировать новый key в обработчике timeout: этот шаг разрушает связь с результатом на backend.'), codeBlock(clientIntentExample), paragraph('В настоящем UI состояние можно держать в модели формы, а при необходимости пережить reload — в явно выбранном хранилище с коротким сроком жизни. Это проектное решение: локальное хранилище удобно, но на общем устройстве может раскрыть содержимое черновика; память вкладки теряется после перезагрузки. Во всех вариантах ключ не является секретом и не заменяет авторизацию. Сервис всё равно связывает запись с аутентифицированным субъектом и операцией, а не делает глобальную таблицу, где чужой key раскрывает чужой результат.'), heading('Бюджет времени ограничивает число безопасных попыток'), paragraph('Timeout — не доказательство отсутствия эффекта. Он означает лишь, что конкретная сторона не дождалась следующего события в своём лимите. Прокси мог отправить request upstream, приложение могло завершить запись, а клиент уже закрыл ожидание. Поэтому retry после timeout всегда использует тот же ключ; отмена ожидания не равна rollback на сервере. В Node 12 ClientRequest.setTimeout даёт обработчику сигнал таймера, но решение оборвать request и дальнейшая судьба сервиса остаются отдельной логикой.'), codeBlock(requestBudget), paragraph('Числа в этой схеме учебные. Их нельзя переносить в production без данных о proxy, приложении и пользовательском сценарии. Полезен сам порядок: общий budget больше одной попытки; первая попытка имеет предел; между попытками есть короткая развилка; финал не обязан снова стучаться в сеть. Если второй ответ не получен, UI сохраняет ключ и показывает, что исход неизвестен, либо предлагает отдельный статусный запрос, если такой read-маршрут предусмотрен контрактом.'), dataTable( 'Решение о повторе: статус не подменяет причину', ['Наблюдение клиента', 'Что неизвестно', 'Проверка контракта', 'Действие'], [ ['Ответ 201 или сохранённый terminal результат', 'ничего о повторе', 'ключ и payload совпали', 'закрыть intent и показать результат'], ['Сетевой timeout после отправки', 'сервер мог выполнить операцию', 'есть ключ и ещё есть общий budget', 'один ограниченный retry тем же key'], ['409 для того же key', 'первая попытка могла идти', 'ответ документирован как in-progress/conflict', 'не создавать второй key; подождать или запросить статус по договору'], ['Ошибка валидации 4xx', 'payload не станет правильным сам', 'в ответе названы поля', 'вернуть форму в редактирование и создать новый intent после изменения'], ['503 с понятной политикой сервиса', 'возможен временный отказ до или после эффекта', 'есть ограничение retries и одинаковый key', 'применить policy операции, не бесконечный цикл'], ], ), paragraph('Не стоит превращать список кодов в автомат. 503 может сопровождаться Retry-After, но этот header не отменяет общий deadline пользователя. 4xx не всегда означает отсутствие записи, если сервер плохо описал порядок работы, поэтому основной критерий всё равно один: retry намерен повторить только тот же intent и передаёт тот же key. Если сервис не поддерживает такой контракт, честнее остановиться, показать неопределённый исход и провести проверку владельцем операции, чем послать вторую команду вслепую.'), heading('Маршрут внедрения в одном сервисе'), orderedList([ 'Выбрать одну операцию с видимым побочным эффектом: создание учебной заявки, резервирование слота или запуск отчёта. Не начинать со всех POST сразу.', 'Описать, что является одним intent: значимые поля payload, аутентифицированный субъект, операция и срок, в который повтор считается тем же действием.', 'Сделать ключ частью модели intent до первого HTTP-вызова. Проверить reload, двойной клик и timeout: ни один путь не создаёт новый key для того же payload.', 'На backend сохранить key вместе с scope, fingerprint и terminal результатом. Параллельный повтор обязан встретить запись, а не второй обработчик.', 'Задать общий временной budget и короткую policy повторов. Проверить отдельно транспортный timeout, ответ валидации, conflict in-progress и повтор готового результата.', 'Пройти изолированную фикстуру «ответ потерян, операция завершена»: первый клиент не видит ответ, второй получает тот же result ID, а счётчик эффектов остаётся равен одному.', ]), heading('Границы практического рецепта'), paragraph('Этот подход не делает любую внешнюю систему идемпотентной. Если обработчик сначала отправляет необратимое письмо, создаёт объект под случайным именем или вызывает чужой API без собственного ключа, локальная запись результата может появиться слишком поздно. Для внешнего эффекта нужен устойчивый идентификатор на той границе: детерминированное имя объекта, уникальный бизнес-ключ или документированный контракт поставщика. В следующем материале разберу, почему одна таблица key сама по себе не закрывает эту гонку.'), paragraph('Здесь не запускались browser, proxy, production API, реальная база и никакая платёжная интеграция. Адрес, payload, result ID, тайминги и заголовок X-Client-Attempt принадлежат учебному примеру. Перед применением команда должна отдельно сверить версию клиента, таймауты посредников, схему аутентификации, политику очистки ключей и конкретный способ получить статус операции после исчерпания budget.'), ], [rfc7231, idempotencyDraft2020, nodeTimeout], ); const mechanismArticle = createRevision( { slug: 'editorial-2020-06-mechanism-retry-idempotency', title: 'Под капотом идемпотентности: ключ, fingerprint и сохранённый HTTP-результат', categories: ['HTTP', 'Backend', 'PostgreSQL'], cover: '/assets/editorial/2020/retry-idempotency-state-contract-2020.svg', excerpt: 'Разбираем запись idempotency на стороне сервиса: scope ключа, защита от параллельного POST, хранение terminal ответа и граница внешнего эффекта.', readingMinutes: 15, }, [ paragraph('Симптом на backend выглядит как два почти одинаковых POST с одним key. Если каждый request сразу вызывает обработчик, две вкладки или retry после медленного соединения создадут две записи. Если же сервис хранит только boolean seen=true, следующий request не знает, был ли первый ещё в работе, закончился ошибкой или успел создать результат до падения процесса. Цена — либо дубль эффекта, либо неопределённый ответ, который фронтенд не может объяснить пользователю.'), paragraph('Причина в том, что идемпотентность — не флаг в middleware, а договор о состоянии операции. Для автора июня 2020 года это стык HTTP, хранилища и delivery: один key резервирует право обработать конкретный intent; fingerprint отделяет настоящий retry от другого payload; terminal запись хранит ровно тот результат, который можно вернуть повтору. В этой статье используются PostgreSQL 12 и псевдокод как учебная граница. Они не описывают базу или API этого блога и не обещают готовый production endpoint.'), heading('У ключа всегда есть scope'), paragraph('Строка ключа не обязана быть уникальной во всей вселенной. Нужен scope, в котором её интерпретирует сервис. Для учебной заявки это разумная тройка: аутентифицированный actor_id, операция demo-request.create и сам idem_key. Тогда одинаковая строка, пришедшая от другого пользователя или в другой операции, не открывает чужой результат и не блокирует несвязанную команду. Scope нельзя строить на X-Client-Attempt: попытка описывает доставку, а не бизнес-эффект.'), paragraph('Fingerprint добавляется к scope, потому что случайное или ошибочное повторное использование key опаснее, чем конфликт. Сервис канонически выбирает именно те поля, которые определяют эффект, получает их hash и сохраняет его при первом request. Повтор с тем же key и тем же fingerprint вправе получить сохранённый ответ. Повтор с тем же key, но другим fingerprint получает conflict и не запускает новое действие. Форма канонизации — часть контракта: порядок полей JSON, пробелы и незначимые display-поля не должны случайно менять решение, но это не повод скрывать от команды, какие поля действительно значимы.'), figure('/assets/editorial/2020/retry-idempotency-state-contract-2020.svg', 'Вертикальная диаграмма состояний одной записи key: новый запрос атомарно резервирует in_progress, параллельный повтор получает conflict, завершённая операция хранит status и body для replay, а mismatch payload останавливается до эффекта', 'Ключ не кэширует «успех вообще». Он связывает один scope, один fingerprint и одно terminal решение, которое может быть повторно показано клиенту.'), heading('Минимальная запись результата'), dataTable( 'Что хранит учебная запись идемпотентности', ['Поле', 'Зачем нужно', 'Чего поле не доказывает', 'Проверка'], [ ['actor_id + operation + idem_key', 'scope и уникальный захват intent', 'глобальную уникальность всех клиентов', 'уникальное ограничение базы содержит все три части'], ['payload_hash', 'разделяет retry и другой payload', 'корректность самой канонизации', 'изменённое значимое поле даёт conflict до обработчика'], ['state', 'различает in_progress и terminal', 'что внешний эффект уже согласован', 'параллельный request не начинает второй обработчик'], ['status_code + response_body', 'даёт повтору тот же наблюдаемый результат', 'что ответ дошёл до первого клиента', 'готовый retry возвращает сохранённый result ID'], ['expires_at', 'задаёт окно deduplication и уборки', 'безопасность позднего повтора после очистки', 'срок больше максимального retry-budget и оговорён в API'], ], ), paragraph('Не требуется сохранять полный сырой request, заголовки авторизации или все диагностические поля. Для replay обычно хватает статуса, минимального ответа, стабильно выбранного result ID и безопасной причины отказа. Это уменьшает риск положить в таблицу данные, которые затем прочитает не тот сотрудник или которые нельзя хранить так долго. Но слишком бедная запись тоже вредна: если в terminal state нет ответа, retry снова вынужден гадать, что показать пользователю.'), heading('Уникальность должна жить там, где конкурируют запросы'), paragraph('Проверка if (!cache.has(key)) в одном Node-процессе не защищает второй процесс, другой pod или рестарт. Поэтому минимальная защита находится в хранилище с уникальным ограничением. В PostgreSQL 12 UNIQUE и INSERT ... ON CONFLICT позволяют одной транзакции выиграть создание строки, а второй увидеть, что место уже занято. После этого второй request читает запись и выбирает ветку replay, in-progress или conflict; он не «пробует ещё раз» вызвать основной обработчик.'), codeBlock(reservationSql), paragraph('SQL выше намеренно не выдаёт полный production schema. Типы actor, TTL, hash и response зависят от проекта. Существеннее порядок: уникальность проверяет база, а не приложение; только владелец вставленной строки продолжает к действию; конкурент сначала читает уже существующее решение. Если команда использует другое хранилище, ей нужно воспроизвести именно эту атомарную развилку, а не перенести название поля idem_key в незащищённую коллекцию.'), heading('Четыре ветки одного key'), dataTable( 'Решение сервиса после чтения записи', ['Наблюдение', 'Причина', 'Проверка', 'Действие HTTP-слоя'], [ ['Вставка прошла', 'key ещё не занят в данном scope', 'текущая транзакция владеет записью', 'обработать intent и сохранить terminal результат'], ['Та же hash, completed', 'первый запрос уже закончен', 'есть status и минимальное response body', 'вернуть сохранённый HTTP-результат без нового эффекта'], ['Та же hash, in_progress', 'первый обработчик ещё не завершил договор', 'нет terminal state', 'вернуть документированный conflict/статус ожидания; не запускать второй обработчик'], ['Другая hash', 'key ошибочно привязан к другому payload', 'сравнение выполнено до business action', 'вернуть 409 Conflict и потребовать новый intent'], ], ), paragraph('В RFC 7231 статус 409 Conflict выражает конфликт с текущим состоянием ресурса. В 2020 году отдельного утверждённого HTTP-стандарта для idempotency key ещё не было, поэтому нельзя приписывать этому коду универсальную семантику. API должен в документации назвать, означает ли 409 payload mismatch, выполняющийся запрос или оба случая с различимым машинным полем. Клиент в любом случае не создаёт новый key автоматически: сначала он понимает, какой именно конфликт увидел.'), heading('Завершаем запись тем же решением, которое увидит клиент'), paragraph('После захвата slot обработчик выполняет бизнес-действие, затем сохраняет terminal status и body. Для операции, эффект которой целиком лежит в той же базе, полезно поставить запись результата и сам объект в одну транзакцию. Тогда после commit либо видны оба факта, либо ни один. Именно здесь idempotency становится проверяемой: второй request находит созданную запись и возвращает тот же requestId, а не создаёт ещё один.'), codeBlock(reservationExample), paragraph('Псевдокод не раскрывает внутреннюю реализацию createDemoRequest, не открывает настоящий HTTP-сервер и не показывает библиотеку базы. В учебной ветке внутреннее создание результата и finishIdempotency лежат в одной транзакции: после commit можно вернуть сохранённый code и безопасное body. Если выбранное действие нельзя включить в эту транзакцию, команда должна прямо назвать, какой этап остаётся внешним и как будет восстанавливаться запись in_progress.'), heading('Внешний эффект не становится атомарным от локальной таблицы'), paragraph('Самая дорогая ошибка появляется, когда сервис отправил запрос наружу, а затем не успел записать completed. При рестарте локальная строка может остаться in_progress, хотя внешняя система уже создала объект. Таймер очистки не решает проблему: удалить ключ и выполнить действие снова означает сознательно создать возможный дубль. Здесь нужен второй договор на внешней границе — стабильный идентификатор объекта, свой idempotency key у поставщика или путь проверки результата по business ID.'), paragraph('Это ограничение меняет дизайн retry. Для внутренней SQL-записи можно говорить о транзакции. Для вызова по сети разумнее сначала выбрать устойчивый внешний ключ, записать намерение, затем сделать вызов и иметь восстановительный маршрут для in_progress. В 2020 году для небольшого сервиса не обязательно вводить event streaming или большую платформу. Достаточно честно назвать, где заканчивается транзакция базы и кто владеет повтором после обрыва именно на внешнем hop-е.'), heading('Срок хранения — часть временного бюджета'), paragraph('Запись key нельзя удалить сразу после 201. Первый ответ мог потеряться, браузер мог сделать retry через паузу, а proxy — закончить свой путь позже клиента. Окно хранения должно быть больше максимального пользовательского budget, разрешённой задержки retry и известных промежуточных timeout для этой операции. Слишком короткий TTL превращает поздний retry в новый effect; слишком длинный срок без политики расширяет таблицу и удерживает результат дольше нужного. Поэтому API фиксирует срок и задаёт отдельную уборку только terminal записей, для которых поздний повтор уже запрещён контрактом.'), heading('Маршрут проверки механизма'), orderedList([ 'Выбрать scope: субъект, операция и key. Убедиться, что одинаковый key другого пользователя не читает и не блокирует чужой результат.', 'Согласовать canonical payload и fingerprint. Изменить одно значимое поле в фикстуре и проверить, что обработчик не запускается повторно.', 'Добавить уникальное ограничение в выбранное устойчивое хранилище и воспроизвести два одновременных INSERT с одним scope/key.', 'Проверить ветки completed, in_progress и conflict: каждая возвращает документированный HTTP-ответ без второго эффекта.', 'Для внутреннего эффекта определить транзакционную границу. Для внешнего — отдельно назвать стабильный идентификатор и способ восстановить зависшую запись.', 'Задать retention длиннее retry-budget, затем проверить очистку terminal записей на тестовом времени; не удалять in-progress как способ спрятать сбой.', ]), heading('Границы механизма'), paragraph('Таблица не заменяет авторизацию, rate limit, валидацию payload и защиту от перегрузки. Она также не делает ключ секретом и не даёт гарантии exactly once между независимыми системами. Её более скромная задача: в одном scope сервис отличает повтор завершённого intent от второго параллельного выполнения и от попытки подменить payload. Этого достаточно, чтобы timeout превратился из повода послать второй POST в конкретную запись, состояние и проверку.'), paragraph('В пакете не поднимались PostgreSQL, HTTP-прокси, browser или реальный сервис. SQL, hash, TTL и ответы — учебные конструкции; адреса, пользовательские данные и credentials отсутствуют. Перед внедрением нужно проверить версию базы, режим изоляции транзакций, реальный объём response body, способ canonicalization, политику хранения и поведение внешних зависимостей при обрыве после принятия запроса.'), ], [rfc7231, idempotencyDraft2020, postgresConstraints, postgresInsert], ); const fieldArticle = createRevision( { slug: 'editorial-2020-06-field-retry-idempotency', title: 'Разбор: ответ потерян, заявка создана — как проверить retry без второго эффекта', categories: ['HTTP', 'Разбор', 'Надёжность'], cover: '/assets/editorial/2020/retry-idempotency-diagnosis-2020.svg', excerpt: 'Учебная трасса «сервер сохранил результат, клиент не увидел ответ»: собираем доказательства, сравниваем бюджет времени и проверяем replay детерминированной фикстурой.', readingMinutes: 14, }, [ paragraph('Симптом в разборе неприятный: пользователь видит timeout, повторяет отправку и получает два одинаковых результата. В журнале первой попытки иногда уже есть 201, но фронтенд его не увидел; иногда есть только запись proxy; иногда в базе нет ничего. Цена ошибки зависит от выбранной догадки. Если команда называет любой timeout «неуспешным запросом», она добавит повтор без ключа и создаст дубль. Если называет любой timeout «сервер точно сделал работу», она остановит пользователя даже там, где request не дошёл до обработчика.'), paragraph('Ниже не production-инцидент, а детерминированная учебная фикстура июня 2020 года. В ней одна заявка на технический разбор, условный edge и память процесса вместо настоящей базы. Нет персональных данных, платёжных операций, реального hostname, browser trace или измерения нагрузки. Фикстура всё же полезна: она сохраняет порядок «первый результат записан → ответ потерян → retry приходит тем же key → сервер делает replay» и не даёт статье выдать красивую схему за доказательство чужой инфраструктуры.'), heading('Разделяем три исхода timeout'), paragraph('Фраза «запрос упал по timeout» скрывает минимум три разных пути. Первый: клиент не дождался соединения, а upstream вообще не увидел request. Второй: proxy или сервис принял request, но обработчик ещё работает. Третий: сервис записал результат и сформировал ответ, но ответ потерялся на пути к клиенту. Только третий путь даёт классическую картину «операция выполнена, UI сообщает ошибку». У всех трёх может быть одинаковая кнопка и похожая ошибка в console, поэтому расследование начинается не с увеличения timeout, а с одного ключа и временной последовательности.'), paragraph('На учебном маршруте я использую два независимых идентификатора. requestId принадлежит одной сетевой попытке и меняется на retry. Idempotency-Key принадлежит одному intent и остаётся тем же. Первый помогает собрать строку edge и строку приложения для конкретного HTTP-прохода. Второй отвечает на другой вопрос: две попытки хотят один effect или два? Подмена одного идентификатора другим ломает разбор: одинаковый requestId в разных hops может быть ошибкой логирования, а новый key на retry гарантированно лишает сервер возможности увидеть повтор.'), figure('/assets/editorial/2020/retry-idempotency-diagnosis-2020.svg', 'Вертикальная временная схема расследования: первая попытка получает requestId req-51 и один idempotency key, сервис сохраняет result ID, ответ не наблюдается клиентом до его дедлайна, вторая попытка req-52 с тем же key получает replay без второго эффекта', 'Для расследования нужны оба измерения: requestId показывает путь одной доставки, а idempotency key связывает две доставки с одним пользовательским действием.'), heading('Карточка доказательств вместо одного статуса'), dataTable( 'Сигнал, гипотеза и действие в учебном разборе', ['Что найдено', 'Что это подтверждает', 'Что ещё неизвестно', 'Следующее действие'], [ ['Edge видит req-51, app не видит key', 'первая попытка дошла не до обработчика', 'был ли upstream доступен позже', 'проверить маршрут proxy и разрешить retry тем же key в общем budget'], ['App пишет in_progress, terminal записи нет', 'обработчик начал работу', 'завершится ли он после таймера клиента', 'не запускать второй handler; вернуть/проверить status по договору'], ['App пишет stored status=201', 'результат сохранён', 'видел ли первый клиент ответ', 'retry должен получить replay того же result ID'], ['Тот же key с другой hash', 'клиент пытается изменить intent', 'почему state формы не сброшен', 'вернуть conflict; создать новый intent только после явного изменения'], ['Две terminal записи с разными result ID', 'идемпотентная граница отсутствует или не атомарна', 'какой из effects уже виден внешней системе', 'остановить blind retry и исследовать порядок записи/внешнего вызова'], ], ), paragraph('Эта таблица не требует полноценной distributed tracing системы. Для первой проверки достаточно согласовать безопасные поля в уже существующих журналах: время, route, requestId, сокращённый или внутренне нормализованный key, решение idempotency и result ID. Полный payload, cookie, authorization и сообщение ошибки внешнего поставщика в такой журнал не кладут. Они не помогают отличить replay от second effect, зато расширяют поверхность данных именно во время сбоя.'), heading('Учебный HTTP-запрос и две разные попытки'), paragraph('Ниже два вызова намеренно используют локальный адрес и один демонстрационный key. Команды не выполнялись в этом пакете: здесь нет сервера на 127.0.0.1:48080. Их задача — сделать проверяемым условие: второй запрос не получает новый ключ и тот же payload не превращается в новую заявку. Если проект использует другой route, JSON serializer или аутентификацию, меняется конкретная команда, но не связь между intent, key и result.'), codeBlock([ '# Учебная команда для изолированного стенда, не production endpoint.', 'curl --max-time 2 --request POST http://127.0.0.1:48080/v1/demo-requests \\', ' --header "Content-Type: application/json" \\', ' --header "Idempotency-Key: demo-key-retry-2020-06-a7f1" \\', ' --data "{\"topic\":\"bundle review\",\"note\":\"fixture\"}"', '', '# После искусственно потерянного ответа повторяем те же bytes и тот же key.', 'curl --max-time 2 --request POST http://127.0.0.1:48080/v1/demo-requests \\', ' --header "Content-Type: application/json" \\', ' --header "Idempotency-Key: demo-key-retry-2020-06-a7f1" \\', ' --data "{\"topic\":\"bundle review\",\"note\":\"fixture\"}"', ].join('\n')), paragraph('Утилита curl --max-time в таком упражнении ограничивает ожидание клиента, но не отменяет request на сервере. Именно это и надо смоделировать: после первого timeout нельзя делать вывод, что записи нет. При наличии idempotency contract второй вызов может честно получить тот же 201 и demo-request-42 — это не «старая ошибка в кеше», а наблюдаемый результат одного intent. Если service contract возвращает иной статус для replay, это также нужно зафиксировать заранее и проверить тестом.'), heading('Журнал отвечает на вопрос о порядке'), paragraph('Учебный след ниже показывает третий исход timeout. req-51 дошёл до приложения; приложение сохранило result; клиент не дождался ответа. После этого req-52 имеет другой requestId, но тот же key и получает решение replay. Строка stored стоит перед client timeout не потому, что edge «всегда быстрее», а потому, что именно такой порядок зафиксирован в данной фикстуре. Реальный разбор должен получить порядок из часов и логов своего контура, а не подогнать их под этот текст.'), codeBlock(traceExample), paragraph('Если в настоящем логе нет одного из полей, не стоит немедленно добавлять десяток новых метрик. Сначала выбрать развилку, которую нельзя решить: дошёл ли request до приложения, успела ли запись стать terminal, или пришёл ли повтор с тем же key. Затем добавить одно безопасное поле и пройти контролируемый запрос на стенде. Так журнал становится техническим контрактом между frontend, proxy и backend, а не списком строк, который читают только после инцидента.'), heading('Сверяем временной budget между сторонами'), dataTable( 'Учебный бюджет одной заявки, а не рекомендованные production числа', ['Участок', 'Лимит в фикстуре', 'Какой факт ограничивает', 'Риск неверного чтения'], [ ['Интерфейс intent', '5000 ms', 'когда UI перестаёт ждать автоматически', 'считать это отменой операции'], ['Первая попытка клиента', '1800 ms', 'когда transport сообщает timeout', 'считать, что upstream не видел request'], ['Пауза перед retry', '250 ms', 'когда ещё есть смысл в том же intent', 'создать новый key ради скорости'], ['Вторая попытка', '1800 ms', 'последняя автоматическая проверка результата', 'повторять бесконечно на перегруженной зависимости'], ['Остаток', '1150 ms', 'время показать неопределённый исход/статус', 'скрыть состояние пользователя за ещё одним request'], ], ), paragraph('Числа намеренно не совпадают с настройками proxy или базы: их здесь нет. В реальном контуре нужно сначала нарисовать, где устанавливаются client, edge и upstream timeout, а затем сделать общий budget достаточным для осмысленного UX, но конечным. Если proxy ждёт дольше клиента, service может продолжить после закрытия вкладки — это не ошибка само по себе. Ошибкой станет отсутствие ключа и terminal записи, из-за чего следующий визит пользователя создаст новый effect вместо чтения старого результата.'), heading('Детерминированная фикстура проверяет именно логическое свойство'), paragraph('В модуле этой партии есть команда node scripts/upgrade-2020-06.mjs --verify-fixture. Она не запускает HTTP, curl, PostgreSQL, proxy или browser. Она держит три записи в памяти: первая обработка сохраняет один result и теряет ответ для клиента; повтор с тем же key читает result; тот же key с другой hash получает conflict. Такая граница полезна для статьи: можно проверить, что refactor не переставил «создать effect» после replay-проверки и не сделал mismatch вторым effect.'), codeBlock([ '// Фактический вывод учебной команды имеет четыре истинных условия:', '{', ' "effects": 1,', ' "checks": {', ' "firstWasUnknownToClient": true,', ' "onlyOneEffect": true,', ' "retryReplayedStoredResult": true,', ' "payloadMismatchRejected": true', ' }', '}', ].join('\n')), paragraph('Фикстура не доказывает, что выбранная база выдерживает гонку, что proxy действительно теряет ответ именно так или что внешний сервис примет дубликат ключа. Она проверяет более узкое свойство учебного алгоритма: один key с одним payload создаёт один result, а повтор получает сохранённый ответ. После выбора реального стека ту же последовательность нужно повторить как интеграционный тест с двумя конкурентными запросами, рестартом между действием и ответом и наблюдением журналов без чувствительных данных.'), heading('Маршрут разбора неопределённого исхода'), orderedList([ 'Взять один конкретный пользовательский intent и найти его idempotency key. Не начинать расследование с агрегированного графика timeout.', 'Собрать по первой попытке requestId, edge-событие, решение backend и terminal result ID. Отметить, какое звено отсутствует, а не заполнять пробел предположением.', 'Проверить повтор: key и значимый payload должны совпадать, а requestId должен быть новым. Если key новый, это уже не доказательство retry.', 'Сопоставить общий budget, лимит первой попытки, паузу и лимит второй попытки. Отдельно назвать, что происходит после исчерпания budget.', 'Запустить учебную или интеграционную фикстуру «response lost after stored result». Проверить один effect, replay одинакового result ID и conflict для другого payload.', 'Если эффект уходит во внешнюю систему, остановить автоматический retry до появления внешнего idempotency contract или безопасного статусного маршрута.', ]), heading('Что этот разбор не утверждает'), paragraph('Он не утверждает, что любой 201 должен кэшироваться навсегда, что 409 имеет одно и то же значение у каждого API или что две системы получат exactly once по одному заголовку. Он показывает более приземлённую технику: разделить unknown outcome на несколько путей, хранить решение по одному intent и проверять порядок следом событий. Это соответствует уровню М3: frontend, delivery и backend уже рассматриваются вместе, но без выдуманной observability stack и без истории о масштабном инциденте.'), paragraph('В пакете реально выполнится только in-memory fixture из revision-модуля; curl-команды, журналы, тайминги и адрес являются учебными данными. Не запускались browser, proxy, production endpoint, нагрузка, база или внешняя система. Перед практическим использованием нужны согласованный API-contract, privacy review журнала, проверка конкуренции в выбранном хранилище, измеренные таймауты и отдельный сценарий восстановления для внешнего эффекта.'), ], [rfc7231, idempotencyDraft2020, nodeTimeout, postgresConstraints], ); export const revisions = [practiceArticle, mechanismArticle, fieldArticle]; export { runLostResponseFixture, verifyFixture }; const isMainModule = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); if (isMainModule) { if (process.argv.includes('--print-revisions')) { process.stdout.write(JSON.stringify(revisions)); } else if (process.argv.includes('--verify-fixture')) { process.stdout.write(JSON.stringify(verifyFixture()) + '\n'); } else { process.stderr.write('Usage: node web/scripts/upgrade-2020-06.mjs --print-revisions | --verify-fixture\n'); } }