diff --git a/editorial/agent-rewrites/197.json b/editorial/agent-rewrites/197.json index e9a60f7..4001d22 100644 --- a/editorial/agent-rewrites/197.json +++ b/editorial/agent-rewrites/197.json @@ -1 +1,7 @@ -{"index":197,"slug":"editorial-2022-07-mechanism-poor-network","title":"Плохая сеть: как не потерять черновик после повтора","excerpt":"Запрос может уйти, а подтверждение — не прийти. Разбираем неизвестный исход, разделяем версию черновика, логическую операцию и попытку, а затем проверяем поздние ответы и безопасный повтор.","contentHtml":"
Пользователь нажимает «Сохранить». Кнопка перестаёт крутиться, но экран не знает, дошёл ли запрос до сервера. Человек нажимает ещё раз. Пока второй запрос ждёт ответа, приходит первый. Если обработчик связывает ответ только с формой, он может закрыть редактор, очистить новый текст или показать успех для операции, которую никто не подтвердил. Цена ошибки — потерянная работа, дублирующее изменение на сервере и расследование по одному скриншоту без доказательств.
\nПлохая сеть не означает только медленный канал. Запрос может завершиться на сервере, но ответ потеряется. Ответ может прийти позже нового ввода. Браузер может сообщить об ошибке транспорта, хотя предметная операция уже изменила данные. Поэтому «нет ответа» не равно «операция не выполнена».
\nТезис статьи: интерфейс должен хранить отдельно версию черновика, логическую операцию и конкретную попытку. Состояние unknown-outcome должно быть видимым. Повтор разрешается только по явному правилу. Поздний ответ меняет экран только после проверки ключей и версии. Этот механизм защищает локальный текст; он не доказывает результат на сервере без согласованного API.
draft.version — версия текста, которую редактирует пользователь. Она меняется после содержательного ввода. Если пользователь дописал абзац, текущая версия уже не та, которую отправила первая попытка.
requestKey — идентификатор одной логической операции. Он отвечает на вопрос «что именно пользователь хочет завершить?». При разрешённом повторе request key сохраняется. Если создать новый ключ при каждом клике, сервер не сможет распознать повтор, если его контракт поддерживает идемпотентность.
attemptKey — идентификатор запуска. Он отвечает на вопрос «какой ответ сейчас ожидает интерфейс?». Первая попытка получает attempt-01, разрешённый повтор — attempt-02. Старый ответ с первым ключом не должен коммититься в состояние, которое ждёт второй.
acknowledgement — сообщение, которое API определило как подтверждение операции. Завершение локального обработчика, исчезновение спиннера и статус «запрос отправлен» подтверждением не являются. В payload нужны данные, по которым клиент проверит request key, attempt key и принятую версию.
| Сущность | Когда меняется | Что защищает | Чего не доказывает |
|---|---|---|---|
| draft.version | При изменении текста | Новый текст от очистки старым ответом | Что текст принят сервером |
| requestKey | При создании новой логической операции | Связь повтора с исходным намерением | Что сервер умеет дедупликацию |
| attemptKey | При каждой разрешённой попытке | Поздний ответ другой попытки | Что запрос дошёл до сервера |
| acknowledgement | После ответа, прошедшего проверки | Переход в acknowledged | Что последующий ввод тоже сохранён |
Флаг isPending описывает только наличие ожидания. Он не говорит, какая версия текста отправлена и какой ответ ещё допустим. После timeout флаг обычно сбрасывают. Если запрос всё ещё выполняется, его поздний ответ получает возможность изменить уже новое состояние.
Кнопка disabled тоже не является защитой. Событие могло попасть в очередь до блокировки. Другой обработчик может вызвать отправку напрямую. Восстановление страницы может загрузить старое pending-состояние. Правило нужно разместить на переходе состояния, а не только в визуальном элементе.
\nУдобная минимальная машина имеет состояния idle, awaiting-ack, unknown-outcome, acknowledged и recovery-required. В awaiting-ack второй запуск блокируется. В unknown-outcome пользователь видит, что результат не установлен. Переход в acknowledged разрешает только проверенный acknowledgement. Переход в recovery-required сохраняет черновик и предлагает сверить результат, если повтор небезопасен.
Ниже не сетевой клиент и не production-код. Это компактная модель переходов. Она показывает только условие, при котором ответ может изменить локальное состояние.
\nconst state = {\n phase: 'awaiting-ack',\n draftVersion: 2,\n requestKey: 'request-17',\n attemptKey: 'attempt-02',\n submittedVersion: 1,\n};\n\nfunction acceptAck(current, ack) {\n if (ack.requestKey !== current.requestKey) {\n return { ...current, phase: 'stale-ack' };\n }\n if (ack.attemptKey !== current.attemptKey) {\n return { ...current, phase: 'stale-ack' };\n }\n if (ack.acceptedVersion !== current.submittedVersion) {\n return { ...current, phase: 'invalid-ack' };\n }\n\n return {\n ...current,\n phase: current.draftVersion === ack.acceptedVersion\n ? 'acknowledged'\n : 'acknowledged-newer-draft-retained',\n };\n}\n\nconst lateAck = {\n requestKey: 'request-17',\n attemptKey: 'attempt-01',\n acceptedVersion: 1,\n};\n\nconst next = acceptAck(state, lateAck);\n// next.phase === 'stale-ack'; draft version 2 не меняется\nВ примере первый ответ пришёл после второго запуска. Его request key совпадает, но attempt key устарел. Функция не очищает черновик и не показывает успех. Если позже придёт acknowledgement для attempt-02, он подтвердит только версию 1. Версия 2 останется черновиком. Это важная граница: подтверждение старой отправки не подтверждает последующий ввод.
В реальном приложении состояние может лежать в reducer, store или другом слое. Названия не важны. Важно, чтобы каждый commit проверял владельца ответа и accepted version в одном месте. Не полагайтесь на порядок прихода событий.
\nПосле transport error интерфейс знает только, что не получил ожидаемый ответ. Он не знает, отменил ли сервер операцию. Поэтому безопасный экран не подменяет неизвестный исход ошибкой без оговорки. Он оставляет черновик, показывает состояние проверки и выбирает действие по цене повтора.
\nДля обычного текста продукт может разрешить один повтор. Он использует прежний request key и новый attempt key. Сервер должен понимать этот контракт, если повтор должен быть идемпотентным. Для финансового или иного необратимого действия повтор может быть запрещён. Тогда интерфейс переводит пользователя в recovery-required и даёт проверить результат через отдельный статусный путь.
\nБесконечный retry опасен. Он создаёт нагрузку, дублирует внешние эффекты и скрывает неизвестный результат за серией одинаковых попыток. Лимит, задержка, ручное подтверждение и способ сверки должны быть частью предметного контракта, а не случайным числом в обработчике.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Новый текст исчез после старого ответа | Ответ не связан с attempt key или version | Сравнить ключи и accepted version перед очисткой draft | Отклонять stale ack и хранить новый draft отдельно |
| Два клика создали две операции | Retry создаёт новый request key | Сопоставить ключи в запросах и на стороне API | Разделить logical request и attempt; согласовать дедупликацию |
| UI показывает успех сразу после клика | Завершение handler-а принято за acknowledgement | Найти место, где phase меняется на success | Разрешать success только после проверки payload |
| После timeout пользователь повторяет необратимое действие | Нет состояния unknown-outcome и recovery-пути | Смоделировать потерю ответа после отправки | Показать неизвестный исход и дать сверить результат |
| Старый ответ закрывает форму | Обработчик смотрит только на форму, а не на попытку | Записать draft version, request key и attempt key каждого события | Проверять владельца ответа до каждого перехода |
Фоновый worker может помочь с очередью или доставкой, но он не превращает неизвестный исход в подтверждённый. У Service Workers есть событийный жизненный цикл. User agent может остановить worker, когда нет события или когда выполнение нарушает ограничения. Поэтому нельзя обещать пользователю «фоновая часть точно завершит сохранение», если приложение не хранит очередь, не описывает acknowledgement и не умеет восстановить состояние.
\nЕсли worker участвует в повторе, добавьте к контракту владельца очереди, срок хранения, версию черновика, правило дедупликации и сообщение для открытой страницы. Проверяйте обновление worker, перезагрузку и несколько вкладок отдельно. Worker в этой модели не запускается; модель не содержит реального сетевого прогона.
\nМодель не решает конфликт двух вкладок, авторизацию, обновление токена, распределённое хранилище, шифрование локального текста, CRDT и серверную транзакцию. Она не определяет, какой HTTP-метод или заголовок должен использовать конкретный API. Идемпотентность метода не гарантирует идемпотентность вашей предметной операции. Это надо доказать контрактом сервиса.
\nУчебный код ограничен памятью процесса. Он не создаёт HTTP-запрос, не имитирует задержку, не проверяет браузер и не заявляет production-результат. Названия requestKey, attemptKey и фаз — проектные значения. Их можно заменить, но нельзя убрать саму проверку связи ответа с операцией и версией.
Критерий готовности закрыт, если команда может показать пять вещей: после потерянного ответа черновик остаётся доступным; повтор не создаёт новую логическую операцию без явного решения; поздний ответ старой попытки не меняет новую версию; acknowledgement старой версии не очищает новый текст; для необратимого эффекта существует отдельная проверка результата. Эти утверждения должны подтверждаться тестом или документированным сценарием с условиями. Один зелёный экран и исчезнувшая кнопка ожидания не являются доказательством.
\nПользователь нажимает «Сохранить». Кнопка перестаёт крутиться, но экран не знает, дошёл ли запрос до сервера. Человек нажимает ещё раз, а затем дописывает абзац. Первый ответ приходит после второго запуска. Если обработчик связывает ответ только с формой, он может закрыть редактор, очистить новый текст или показать успех для операции, которую никто не подтвердил. Цена ошибки — потерянная работа, дубль изменения и расследование по одному скриншоту.
\nПлохая сеть — это не только медленный канал. Запрос может быть принят сервером, а ответ потеряться; ответ может прийти после нового ввода; транспортная ошибка может сообщить лишь об отсутствии ответа у клиента. Поэтому «клиент не получил подтверждение» и «сервер не выполнил операцию» — разные утверждения.
\nВ этой статье описана учебная модель сохранения черновика. Она разделяет текущую версию текста, логический запрос, отдельную попытку доставки и подтверждение предметной операции. Модель не определяет API и не доказывает состояние сервера. Её задача — не дать позднему событию без проверки изменить локальный интерфейс.
\ndraft.version принадлежит редактору. После содержательного ввода версия увеличивается, поэтому текст версии 2 нельзя очищать подтверждением, которое относится к отправленной версии 1.
requestKey обозначает одно логическое намерение: например, «сохранить версию 1 этого черновика». При разрешённом повторе он сохраняется. Сервер может использовать такой ключ для дедупликации только тогда, когда это предусмотрено его контрактом; сама строка на стороне клиента идемпотентность не создаёт.
attemptKey обозначает конкретный запуск доставки. Повтор получает новый ключ, чтобы клиент различал первый и второй ответы. Это ключ корреляции и защиты перехода UI, а не доказательство, что сервер выполнил или не выполнил операцию.
acknowledgement — подтверждение от предметного API. Оно должно назвать логический запрос и принятую версию, а при необходимости — идентификатор операции или результат проверки. Исчезнувший спиннер, завершившийся обработчик и сообщение «запрос отправлен» подтверждением не являются.
| Сущность | Когда меняется | Что защищает | Чего не доказывает |
|---|---|---|---|
| draft.version | После изменения текста | Новый текст от очистки старым ответом | Что сервер принял текст |
| requestKey | При создании новой логической операции | Связь повтора с исходным намерением | Что API распознаёт ключ |
| attemptKey | При каждой разрешённой попытке | Ответ от неверного запуска | Что запрос дошёл до сервера |
| acknowledgement | После проверки payload | Переход к подтверждённому результату | Что последующий ввод тоже сохранён |
Флаг isPending отвечает только на вопрос «есть ли ожидание». Он не хранит отправленную версию и не показывает, какому событию разрешено менять состояние. После timeout флаг часто сбрасывают, хотя первый запрос может продолжать выполняться. Его поздний ответ тогда получает доступ к уже изменённой форме.
Блокировка кнопки не закрывает эту дыру. Событие могло попасть в очередь до блокировки, другой обработчик может вызвать отправку напрямую, а восстановление страницы — загрузить старое ожидание. Guard должен проверять переход состояния рядом с обработкой результата, а не только менять внешний вид кнопки.
\nМинимальная машина может содержать idle, awaiting-ack, unknown-outcome, acknowledged и reconciliation-required. В unknown-outcome клиент не получил ожидаемое подтверждение и не имеет права объявить операцию отменённой. В reconciliation-required нужно сверить результат по отдельному статусному пути или показать человеку безопасный способ проверки.
Ниже — синхронная модель в памяти. Она намеренно не вызывает fetch, не создаёт задержку и не имитирует сервер. Контракт выбирает строгий guard: ответ другой попытки не меняет активный экран. Даже если в нём совпадают логический ключ и версия, факт операции следует сверить через доменный status endpoint, а не автоматически считать текущим успехом.
const state = {\n phase: 'awaiting-ack',\n draftVersion: 2,\n requestKey: 'request-17',\n activeAttemptKey: 'attempt-02',\n submittedVersion: 1,\n};\n\nfunction applyAck(current, ack) {\n if (ack.requestKey !== current.requestKey) {\n return { ...current, event: 'foreign-ack' };\n }\n if (ack.acceptedVersion !== current.submittedVersion) {\n return { ...current, event: 'invalid-version' };\n }\n if (ack.attemptKey !== current.activeAttemptKey) {\n return { ...current, phase: 'reconciliation-required', event: 'late-ack-needs-status-check' };\n }\n\n return {\n ...current,\n phase: current.draftVersion === ack.acceptedVersion\n ? 'acknowledged'\n : 'acknowledged-newer-draft-retained',\n event: 'acknowledged',\n };\n}\n\nconst lateAck = {\n requestKey: 'request-17',\n attemptKey: 'attempt-01',\n acceptedVersion: 1,\n};\n\nconst next = applyAck(state, lateAck);\n// next.phase === 'reconciliation-required'\n// state.draftVersion === 2 remains untouched\nВызов возвращает reconciliation-required: старый ответ не очищает версию 2 и не показывает ей зелёный success. Одновременно совпавший requestKey нельзя трактовать как доказательство, что сервер ничего не сделал. Поэтому следующий шаг — запросить состояние операции по предусмотренному API. Это более точная модель, чем безусловно выбросить поздний ответ или принять его как текущий успех.
Если API заранее гарантирует, что acknowledgement с тем же логическим ключом и принятой версией можно безопасно применить независимо от attempt key, это отдельный контракт. Его нужно описать и протестировать явно. Учебный пример выше выбирает консервативное правило для активного UI.
\nПосле ошибки транспорта интерфейс знает только, что ожидаемого подтверждения нет. Он не знает, был ли запрос обработан. Это состояние следует назвать unknown-outcome, сохранить локальный черновик и выбрать действие по цене возможного повтора.
Для обычного сохранения продукта может быть достаточно одного осознанного retry: тот же requestKey, новый attemptKey и серверная дедупликация, если она поддерживается. Для платежа, бронирования или другого необратимого эффекта автоматический повтор допустим только при доказуемой идемпотентной семантике либо при отдельной проверке, что операция не была применена.
RFC 9110 определяет идемпотентность через намеренный эффект одинаковых запросов и допускает автоматический повтор после потери ответа для таких методов. Это свойство метода не превращает произвольную прикладную операцию в безопасную для повтора. Тело запроса, предметный эффект, ключ операции и поведение API нужно рассматривать вместе.
\nБесконечный retry опасен: он создаёт нагрузку, дублирует внешний эффект и прячет неизвестный результат за серией одинаковых кликов. Лимит, задержка, ручное подтверждение и статусный путь должны быть частью контракта, а не случайным числом в обработчике.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Новый текст исчез после старого ответа | Очистка не связана с версией | Сравнить submitted и current draft version до commit | Отклонять старый переход и сохранять новый черновик |
| Два клика создали две операции | Retry создаёт новый requestKey или API игнорирует ключ | Сопоставить ключи в запросах и на серверном журнале | Сохранить logical key и согласовать дедупликацию |
| Успех показан после окончания handler-а | Локальное завершение принято за acknowledgement | Найти переход в success и его входные данные | Разрешать success только после проверки payload |
| После timeout повторили необратимое действие | Нет unknown-outcome и status-пути | Смоделировать потерю ответа после отправки | Показать неизвестный результат и сверить операцию |
| Старый ответ закрывает форму | Проверяется форма, но не владелец события | Записать requestKey, attemptKey и версии каждого события | Поставить guard перед каждым изменением UI |
draftVersion, requestKey, attemptKey и submittedVersion. Не записывайте bearer-токены и чувствительный текст.Service Worker может перехватывать fetch-события и участвовать в offline-сценарии, но его наличие не подтверждает выполнение предметной операции. В исторической спецификации W3C от 12 июля 2022 года жизненный цикл worker связан с событиями, а user agent может остановить worker при отсутствии события или при нарушении ограничений выполнения.
\nЕсли worker участвует в очереди, контракту нужны владелец, срок хранения, версия черновика, правило дедупликации, восстановление после перезапуска и сообщение открытой странице. Эти свойства проверяют отдельно. Учебная модель worker не запускает и не обещает фоновой доставки.
\nМодель не решает конфликт двух вкладок, авторизацию, обновление токена, шифрование локального текста, CRDT, несколько устройств и серверную транзакцию. Она не определяет HTTP-метод, заголовок или формат idempotency key для конкретного продукта. Идемпотентность HTTP-метода не гарантирует идемпотентность вашей предметной операции.
\nИзменение готово к следующему инженерному шагу, если тест показывает четыре факта: отсутствие ответа не становится успехом; черновик остаётся доступным; разрешённый retry сохраняет логический ключ; поздний ответ не меняет новую версию. Для необратимого эффекта дополнительно должна существовать сверка результата. Один зелёный экран и исчезнувший спиннер доказательством не являются.
\n