Files

8 lines
22 KiB
JSON
Raw Permalink 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.
{
"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>"
}