{"index":197,"slug":"editorial-2022-07-mechanism-poor-network","title":"Плохая сеть: как не потерять черновик после повтора","excerpt":"Запрос может уйти, а подтверждение — не прийти. Разбираем неизвестный исход, разделяем версию черновика, логическую операцию и попытку, а затем проверяем поздние ответы и безопасный повтор.","contentHtml":"

Пользователь нажимает «Сохранить». Кнопка перестаёт крутиться, но экран не знает, дошёл ли запрос до сервера. Человек нажимает ещё раз. Пока второй запрос ждёт ответа, приходит первый. Если обработчик связывает ответ только с формой, он может закрыть редактор, очистить новый текст или показать успех для операции, которую никто не подтвердил. Цена ошибки — потерянная работа, дублирующее изменение на сервере и расследование по одному скриншоту без доказательств.

\n

Плохая сеть не означает только медленный канал. Запрос может завершиться на сервере, но ответ потеряется. Ответ может прийти позже нового ввода. Браузер может сообщить об ошибке транспорта, хотя предметная операция уже изменила данные. Поэтому «нет ответа» не равно «операция не выполнена».

\n

Тезис статьи: интерфейс должен хранить отдельно версию черновика, логическую операцию и конкретную попытку. Состояние unknown-outcome должно быть видимым. Повтор разрешается только по явному правилу. Поздний ответ меняет экран только после проверки ключей и версии. Этот механизм защищает локальный текст; он не доказывает результат на сервере без согласованного API.

\n

Сначала разделите четыре состояния

\n

draft.version — версия текста, которую редактирует пользователь. Она меняется после содержательного ввода. Если пользователь дописал абзац, текущая версия уже не та, которую отправила первая попытка.

\n

requestKey — идентификатор одной логической операции. Он отвечает на вопрос «что именно пользователь хочет завершить?». При разрешённом повторе request key сохраняется. Если создать новый ключ при каждом клике, сервер не сможет распознать повтор, если его контракт поддерживает идемпотентность.

\n

attemptKey — идентификатор запуска. Он отвечает на вопрос «какой ответ сейчас ожидает интерфейс?». Первая попытка получает attempt-01, разрешённый повтор — attempt-02. Старый ответ с первым ключом не должен коммититься в состояние, которое ждёт второй.

\n

acknowledgement — сообщение, которое API определило как подтверждение операции. Завершение локального обработчика, исчезновение спиннера и статус «запрос отправлен» подтверждением не являются. В payload нужны данные, по которым клиент проверит request key, attempt key и принятую версию.

\n
Границы состояния в учебном контракте
СущностьКогда меняетсяЧто защищаетЧего не доказывает
draft.versionПри изменении текстаНовый текст от очистки старым ответомЧто текст принят сервером
requestKeyПри создании новой логической операцииСвязь повтора с исходным намерениемЧто сервер умеет дедупликацию
attemptKeyПри каждой разрешённой попыткеПоздний ответ другой попыткиЧто запрос дошёл до сервера
acknowledgementПосле ответа, прошедшего проверкиПереход в acknowledgedЧто последующий ввод тоже сохранён
\n

Почему одного isPending недостаточно

\n

Флаг isPending описывает только наличие ожидания. Он не говорит, какая версия текста отправлена и какой ответ ещё допустим. После timeout флаг обычно сбрасывают. Если запрос всё ещё выполняется, его поздний ответ получает возможность изменить уже новое состояние.

\n

Кнопка disabled тоже не является защитой. Событие могло попасть в очередь до блокировки. Другой обработчик может вызвать отправку напрямую. Восстановление страницы может загрузить старое pending-состояние. Правило нужно разместить на переходе состояния, а не только в визуальном элементе.

\n

Удобная минимальная машина имеет состояния idle, awaiting-ack, unknown-outcome, acknowledged и recovery-required. В awaiting-ack второй запуск блокируется. В unknown-outcome пользователь видит, что результат не установлен. Переход в acknowledged разрешает только проверенный acknowledgement. Переход в recovery-required сохраняет черновик и предлагает сверить результат, если повтор небезопасен.

\n

Учебный пример с поздним ответом

\n

Ниже не сетевой клиент и не production-код. Это компактная модель переходов. Она показывает только условие, при котором ответ может изменить локальное состояние.

\n
const 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 останется черновиком. Это важная граница: подтверждение старой отправки не подтверждает последующий ввод.

\n

В реальном приложении состояние может лежать в reducer, store или другом слое. Названия не важны. Важно, чтобы каждый commit проверял владельца ответа и accepted version в одном месте. Не полагайтесь на порядок прихода событий.

\n
\"Схема
Иллюстрация показывает учебный контракт: новый текст сохраняет свою версию, повтор использует тот же request key и новый attempt key, а устаревший ответ не меняет текущий черновик.
\n

Неизвестный исход и повтор

\n

После transport error интерфейс знает только, что не получил ожидаемый ответ. Он не знает, отменил ли сервер операцию. Поэтому безопасный экран не подменяет неизвестный исход ошибкой без оговорки. Он оставляет черновик, показывает состояние проверки и выбирает действие по цене повтора.

\n

Для обычного текста продукт может разрешить один повтор. Он использует прежний request key и новый attempt key. Сервер должен понимать этот контракт, если повтор должен быть идемпотентным. Для финансового или иного необратимого действия повтор может быть запрещён. Тогда интерфейс переводит пользователя в recovery-required и даёт проверить результат через отдельный статусный путь.

\n

Бесконечный retry опасен. Он создаёт нагрузку, дублирует внешние эффекты и скрывает неизвестный результат за серией одинаковых попыток. Лимит, задержка, ручное подтверждение и способ сверки должны быть частью предметного контракта, а не случайным числом в обработчике.

\n

Симптом → причина → проверка → действие

\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 каждого событияПроверять владельца ответа до каждого перехода
\n

Порядок действий

\n
  1. Зафиксируйте симптом. Запишите видимый текст, действие пользователя и цену ошибки. Не называйте проблему «offline», пока не отделили ошибку транспорта от неизвестного результата.
  2. Назовите версии. Выведите безопасные surrogate-значения для draft version, request key и attempt key. Не записывайте bearer-токены и чувствительный текст.
  3. Разделите переходы. Найдите места, где код создаёт попытку, очищает draft, показывает success и разрешает retry. Для каждого перехода укажите владельца.
  4. Проверьте отрицательный путь. Запустите сценарий: первая попытка без acknowledgement, новый ввод, повтор с новым attempt key, поздний ответ первой попытки. Ожидайте stale-результат без изменения нового draft.
  5. Проверьте положительный путь. Передайте acknowledgement с текущими request key, attempt key и accepted version. Убедитесь, что только он переводит состояние в acknowledged.
  6. Выберите политику повтора. Для каждой операции укажите лимит, сохранение request key, новый attempt key и действие при unknown-outcome. Для необратимого эффекта добавьте отдельную сверку.
  7. Согласуйте API. Определите, как сервер распознаёт повтор, что возвращает acknowledgement и как клиент получает статус операции после потерянного ответа.
  8. Проверьте реальную среду. Проведите отдельный browser/network-сценарий с указанными версиями браузера, приложения, API и способом потери ответа. Учебный код не заменяет этот результат.
  9. Оставьте наблюдение. Измеряйте долю unknown-outcome, stale acknowledgement, duplicate attempt и recovery-required. Значения ключей не должны раскрывать секреты.
\n

Service Worker не отменяет контракт

\n

Фоновый worker может помочь с очередью или доставкой, но он не превращает неизвестный исход в подтверждённый. У Service Workers есть событийный жизненный цикл. User agent может остановить worker, когда нет события или когда выполнение нарушает ограничения. Поэтому нельзя обещать пользователю «фоновая часть точно завершит сохранение», если приложение не хранит очередь, не описывает acknowledgement и не умеет восстановить состояние.

\n

Если worker участвует в повторе, добавьте к контракту владельца очереди, срок хранения, версию черновика, правило дедупликации и сообщение для открытой страницы. Проверяйте обновление worker, перезагрузку и несколько вкладок отдельно. Worker в этой модели не запускается; модель не содержит реального сетевого прогона.

\n

Ограничения и критерий готовности

\n

Модель не решает конфликт двух вкладок, авторизацию, обновление токена, распределённое хранилище, шифрование локального текста, CRDT и серверную транзакцию. Она не определяет, какой HTTP-метод или заголовок должен использовать конкретный API. Идемпотентность метода не гарантирует идемпотентность вашей предметной операции. Это надо доказать контрактом сервиса.

\n

Учебный код ограничен памятью процесса. Он не создаёт HTTP-запрос, не имитирует задержку, не проверяет браузер и не заявляет production-результат. Названия requestKey, attemptKey и фаз — проектные значения. Их можно заменить, но нельзя убрать саму проверку связи ответа с операцией и версией.

\n

Критерий готовности закрыт, если команда может показать пять вещей: после потерянного ответа черновик остаётся доступным; повтор не создаёт новую логическую операцию без явного решения; поздний ответ старой попытки не меняет новую версию; acknowledgement старой версии не очищает новый текст; для необратимого эффекта существует отдельная проверка результата. Эти утверждения должны подтверждаться тестом или документированным сценарием с условиями. Один зелёный экран и исчезнувшая кнопка ожидания не являются доказательством.

\n

Проверяемые источники

\n"}