8 lines
22 KiB
JSON
8 lines
22 KiB
JSON
{
|
||
"index": 197,
|
||
"slug": "editorial-2022-07-mechanism-poor-network",
|
||
"title": "Плохая сеть: как разделить попытку, подтверждение и черновик",
|
||
"excerpt": "Запрос может быть обработан, а ответ — потерян. Разбираем контракт, в котором версия черновика, логическая операция и попытка доставки живут отдельно, а поздний ответ не стирает новую работу.",
|
||
"contentHtml": "<p>Пользователь нажимает «Сохранить». Кнопка перестаёт крутиться, но экран не знает, дошёл ли запрос до сервера. Человек нажимает ещё раз, а затем дописывает абзац. Первый ответ приходит после второго запуска. Если обработчик связывает ответ только с формой, он может закрыть редактор, очистить новый текст или показать успех для операции, которую никто не подтвердил. Цена ошибки — потерянная работа, дубль изменения и расследование по одному скриншоту.</p>\n<p>Плохая сеть — это не только медленный канал. Запрос может быть принят сервером, а ответ потеряться; ответ может прийти после нового ввода; транспортная ошибка может сообщить лишь об отсутствии ответа у клиента. Поэтому «клиент не получил подтверждение» и «сервер не выполнил операцию» — разные утверждения.</p>\n<p>В этой статье описана учебная модель сохранения черновика. Она разделяет текущую версию текста, логический запрос, отдельную попытку доставки и подтверждение предметной операции. Модель не определяет API и не доказывает состояние сервера. Её задача — не дать позднему событию без проверки изменить локальный интерфейс.</p>\n<h2>Сначала разделите четыре сущности</h2>\n<p><code>draft.version</code> принадлежит редактору. После содержательного ввода версия увеличивается, поэтому текст версии 2 нельзя очищать подтверждением, которое относится к отправленной версии 1.</p>\n<p><code>requestKey</code> обозначает одно логическое намерение: например, «сохранить версию 1 этого черновика». При разрешённом повторе он сохраняется. Сервер может использовать такой ключ для дедупликации только тогда, когда это предусмотрено его контрактом; сама строка на стороне клиента идемпотентность не создаёт.</p>\n<p><code>attemptKey</code> обозначает конкретный запуск доставки. Повтор получает новый ключ, чтобы клиент различал первый и второй ответы. Это ключ корреляции и защиты перехода UI, а не доказательство, что сервер выполнил или не выполнил операцию.</p>\n<p><code>acknowledgement</code> — подтверждение от предметного API. Оно должно назвать логический запрос и принятую версию, а при необходимости — идентификатор операции или результат проверки. Исчезнувший спиннер, завершившийся обработчик и сообщение «запрос отправлен» подтверждением не являются.</p>\n<div class=\"table-scroll\"><table><caption>Границы данных в учебном контракте</caption><thead><tr><th scope=\"col\">Сущность</th><th scope=\"col\">Когда меняется</th><th scope=\"col\">Что защищает</th><th scope=\"col\">Чего не доказывает</th></tr></thead><tbody><tr><td>draft.version</td><td>После изменения текста</td><td>Новый текст от очистки старым ответом</td><td>Что сервер принял текст</td></tr><tr><td>requestKey</td><td>При создании новой логической операции</td><td>Связь повтора с исходным намерением</td><td>Что API распознаёт ключ</td></tr><tr><td>attemptKey</td><td>При каждой разрешённой попытке</td><td>Ответ от неверного запуска</td><td>Что запрос дошёл до сервера</td></tr><tr><td>acknowledgement</td><td>После проверки payload</td><td>Переход к подтверждённому результату</td><td>Что последующий ввод тоже сохранён</td></tr></tbody></table></div>\n<h2>Почему одного pending недостаточно</h2>\n<p>Флаг <code>isPending</code> отвечает только на вопрос «есть ли ожидание». Он не хранит отправленную версию и не показывает, какому событию разрешено менять состояние. После timeout флаг часто сбрасывают, хотя первый запрос может продолжать выполняться. Его поздний ответ тогда получает доступ к уже изменённой форме.</p>\n<p>Блокировка кнопки не закрывает эту дыру. Событие могло попасть в очередь до блокировки, другой обработчик может вызвать отправку напрямую, а восстановление страницы — загрузить старое ожидание. Guard должен проверять переход состояния рядом с обработкой результата, а не только менять внешний вид кнопки.</p>\n<p>Минимальная машина может содержать <code>idle</code>, <code>awaiting-ack</code>, <code>unknown-outcome</code>, <code>acknowledged</code> и <code>reconciliation-required</code>. В <code>unknown-outcome</code> клиент не получил ожидаемое подтверждение и не имеет права объявить операцию отменённой. В <code>reconciliation-required</code> нужно сверить результат по отдельному статусному пути или показать человеку безопасный способ проверки.</p>\n<h2>Учебный код: поздний ответ не коммитится вслепую</h2>\n<p>Ниже — синхронная модель в памяти. Она намеренно не вызывает <code>fetch</code>, не создаёт задержку и не имитирует сервер. Контракт выбирает строгий guard: ответ другой попытки не меняет активный экран. Даже если в нём совпадают логический ключ и версия, факт операции следует сверить через доменный status endpoint, а не автоматически считать текущим успехом.</p>\n<pre><code>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</code></pre>\n<p>Вызов возвращает <code>reconciliation-required</code>: старый ответ не очищает версию 2 и не показывает ей зелёный success. Одновременно совпавший <code>requestKey</code> нельзя трактовать как доказательство, что сервер ничего не сделал. Поэтому следующий шаг — запросить состояние операции по предусмотренному API. Это более точная модель, чем безусловно выбросить поздний ответ или принять его как текущий успех.</p>\n<p>Если API заранее гарантирует, что acknowledgement с тем же логическим ключом и принятой версией можно безопасно применить независимо от attempt key, это отдельный контракт. Его нужно описать и протестировать явно. Учебный пример выше выбирает консервативное правило для активного UI.</p>\n<figure><img src=\"/assets/editorial/2022/poor-network-retry-contract-2022.svg\" alt=\"Схема разделения версии черновика, логического ключа, двух попыток и подтверждения при плохой сети\" loading=\"lazy\" /><figcaption>Иллюстрация показывает строгий retry-guard: повтор сохраняет логический ключ, получает новую попытку, а ответ старого запуска не меняет активный UI. Состояние операции при этом проверяется отдельным путём.</figcaption></figure>\n<h2>Неизвестный исход и право на повтор</h2>\n<p>После ошибки транспорта интерфейс знает только, что ожидаемого подтверждения нет. Он не знает, был ли запрос обработан. Это состояние следует назвать <code>unknown-outcome</code>, сохранить локальный черновик и выбрать действие по цене возможного повтора.</p>\n<p>Для обычного сохранения продукта может быть достаточно одного осознанного retry: тот же <code>requestKey</code>, новый <code>attemptKey</code> и серверная дедупликация, если она поддерживается. Для платежа, бронирования или другого необратимого эффекта автоматический повтор допустим только при доказуемой идемпотентной семантике либо при отдельной проверке, что операция не была применена.</p>\n<p>RFC 9110 определяет идемпотентность через намеренный эффект одинаковых запросов и допускает автоматический повтор после потери ответа для таких методов. Это свойство метода не превращает произвольную прикладную операцию в безопасную для повтора. Тело запроса, предметный эффект, ключ операции и поведение API нужно рассматривать вместе.</p>\n<p>Бесконечный retry опасен: он создаёт нагрузку, дублирует внешний эффект и прячет неизвестный результат за серией одинаковых кликов. Лимит, задержка, ручное подтверждение и статусный путь должны быть частью контракта, а не случайным числом в обработчике.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><caption>Диагностика гонки ответа и черновика</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Новый текст исчез после старого ответа</td><td>Очистка не связана с версией</td><td>Сравнить submitted и current draft version до commit</td><td>Отклонять старый переход и сохранять новый черновик</td></tr><tr><td>Два клика создали две операции</td><td>Retry создаёт новый requestKey или API игнорирует ключ</td><td>Сопоставить ключи в запросах и на серверном журнале</td><td>Сохранить logical key и согласовать дедупликацию</td></tr><tr><td>Успех показан после окончания handler-а</td><td>Локальное завершение принято за acknowledgement</td><td>Найти переход в success и его входные данные</td><td>Разрешать success только после проверки payload</td></tr><tr><td>После timeout повторили необратимое действие</td><td>Нет unknown-outcome и status-пути</td><td>Смоделировать потерю ответа после отправки</td><td>Показать неизвестный результат и сверить операцию</td></tr><tr><td>Старый ответ закрывает форму</td><td>Проверяется форма, но не владелец события</td><td>Записать requestKey, attemptKey и версии каждого события</td><td>Поставить guard перед каждым изменением UI</td></tr></tbody></table></div>\n<h2>Порядок проверки</h2>\n<ol><li><strong>Зафиксируйте симптом.</strong> Запишите действие пользователя, видимый текст и цену ошибки. Не называйте проблему offline, пока не отделили транспортный сбой от неизвестного результата.</li><li><strong>Назовите снимки.</strong> Зафиксируйте <code>draftVersion</code>, <code>requestKey</code>, <code>attemptKey</code> и <code>submittedVersion</code>. Не записывайте bearer-токены и чувствительный текст.</li><li><strong>Найдите владельцев.</strong> Покажите место создания логической операции, попытки, очистки черновика, перехода в success и статусной сверки.</li><li><strong>Проверьте отрицательный путь.</strong> Дайте первой попытке потерять acknowledgement, измените черновик, запустите разрешённый retry и доставьте поздний ответ первой попытки. Новый текст должен остаться на месте.</li><li><strong>Проверьте положительный путь.</strong> Передайте acknowledgement с текущими ключами и принятой версией. Только этот payload должен перевести активный UI в подтверждённое состояние.</li><li><strong>Проверьте сверку.</strong> Для позднего ответа с тем же requestKey запросите состояние операции отдельным API. Убедитесь, что результат сверки не очищает более новую версию черновика.</li><li><strong>Назначьте политику.</strong> Для каждого действия укажите лимит retry, условие идемпотентности, владельца status-пути и текст для unknown-outcome.</li><li><strong>Прогоните среду.</strong> Повторите сценарий в контролируемом browser/network-тесте с указанными версиями приложения и API. Учебный код не заменяет этот результат.</li></ol>\n<h2>Service Worker не является подтверждением доставки</h2>\n<p>Service Worker может перехватывать fetch-события и участвовать в offline-сценарии, но его наличие не подтверждает выполнение предметной операции. В исторической спецификации W3C от 12 июля 2022 года жизненный цикл worker связан с событиями, а user agent может остановить worker при отсутствии события или при нарушении ограничений выполнения.</p>\n<p>Если worker участвует в очереди, контракту нужны владелец, срок хранения, версия черновика, правило дедупликации, восстановление после перезапуска и сообщение открытой странице. Эти свойства проверяют отдельно. Учебная модель worker не запускает и не обещает фоновой доставки.</p>\n<h2>Ограничения и критерий готовности</h2>\n<p>Модель не решает конфликт двух вкладок, авторизацию, обновление токена, шифрование локального текста, CRDT, несколько устройств и серверную транзакцию. Она не определяет HTTP-метод, заголовок или формат idempotency key для конкретного продукта. Идемпотентность HTTP-метода не гарантирует идемпотентность вашей предметной операции.</p>\n<p>Изменение готово к следующему инженерному шагу, если тест показывает четыре факта: отсутствие ответа не становится успехом; черновик остаётся доступным; разрешённый retry сохраняет логический ключ; поздний ответ не меняет новую версию. Для необратимого эффекта дополнительно должна существовать сверка результата. Один зелёный экран и исчезнувший спиннер доказательством не являются.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://fetch.spec.whatwg.org/review-drafts/2021-12/\" target=\"_blank\" rel=\"noopener noreferrer\">WHATWG Fetch Review Draft, December 2021</a> — зафиксированная версия спецификации запросов, ответов и fetching; она описывает платформенный транспорт, а не смысл сохранения черновика.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110: HTTP Semantics</a> — нормативное описание идемпотентности и повторов после коммуникационной ошибки; прикладной ключ и подтверждение остаются контрактом API.</li><li><a href=\"https://www.w3.org/TR/2022/CRD-service-workers-20220712/\" target=\"_blank\" rel=\"noopener noreferrer\">W3C Service Workers Candidate Recommendation Draft, 12 July 2022</a> — историческая спецификация событийного worker и его ограниченного жизненного цикла; она не подтверждает доставку конкретной очереди.</li></ul>"
|
||
}
|