8 lines
16 KiB
JSON
8 lines
16 KiB
JSON
{
|
||
"index": 198,
|
||
"slug": "editorial-2022-07-practice-poor-network",
|
||
"title": "Плохая сеть: как не объявить неизвестный результат успехом",
|
||
"excerpt": "Практический контракт для формы, которая сохраняет черновик, различает попытку и подтверждение и безопасно переживает потерю ответа.",
|
||
"contentHtml": "<p>Пользователь нажимает «Сохранить». Кнопка перестаёт крутиться, экран показывает зелёное сообщение, а соединение в этот момент уже потеряло ответ. При следующем открытии формы текст пропадает. При повторном нажатии приложение может создать вторую операцию. Это не косметический дефект интерфейса. Человек теряет работу, а команда получает состояние, которое нельзя объяснить одним HTTP-кодом.</p>\n<p>Похожий симптом возникает и без полного offline. Запрос ушёл, сервер мог его принять, но клиент не дождался ответа. Таймаут сообщает только о том, что клиент не получил результат вовремя. Он не доказывает, что предметное действие не произошло. Поэтому цена ошибки появляется на стыке транспорта и бизнес-состояния: приложение превращает «результат неизвестен» в «ошибка» или «успех».</p>\n<h2>Тезис: успех подтверждает предметная операция, а не кнопка</h2>\n<p>Интерфейс должен разделять четыре сущности: текущий черновик, логический запрос, отдельную попытку доставки и подтверждение операции. Черновик принадлежит пользователю. Логический запрос описывает одно намерение: сохранить версию 3. Попытка показывает, сколько раз клиент пробовал доставить это намерение. Подтверждение приходит от предметного API и указывает, какую версию оно приняло.</p>\n<p>Такое разделение сохраняет отрицательный путь. Если ответ не пришёл, UI не очищает черновик и не показывает success. Он переходит в <code>unknown-outcome</code>, оставляет текст доступным и предлагает проверку или один осознанный повтор. Если позже приходит старый ответ, он не должен стереть новую версию. Поздний payload — это событие, которое нужно классифицировать, а не безусловно применить.</p>\n<h2>Механизм состояния</h2>\n<p>Для каждой отправки зафиксируйте <code>draftVersion</code>, <code>requestKey</code> и <code>attemptKey</code>. <code>draftVersion</code> меняется после редактирования. <code>requestKey</code> остаётся одним и тем же для логического сохранения. <code>attemptKey</code> меняется при разрешённом retry. Acknowledgement должен содержать ключ логического запроса и принятую версию. Обработчик сравнивает их с текущим состоянием до очистки черновика.</p>\n<p>Ключ запроса не обязан называться именно так. В одном API это может быть <code>operationId</code>, в другом — идемпотency key. Важно назначение: повтор доставки не должен незаметно менять смысл операции. Серверный контракт должен решить, что происходит при повторном ключе. Клиент не может получить идемпотентность из одного только заголовка, если сервер его игнорирует.</p>\n<pre><code>function applyAcknowledgement(state, ack) {\n if (ack.requestKey !== state.requestKey) {\n return { ...state, event: 'stale-request' };\n }\n\n if (ack.acceptedVersion < state.draftVersion) {\n return { ...state, event: 'newer-draft-retained' };\n }\n\n if (state.phase === 'confirmed') {\n return { ...state, event: 'duplicate-acknowledgement' };\n }\n\n return {\n ...state,\n phase: 'confirmed',\n confirmedVersion: ack.acceptedVersion,\n event: 'confirmed',\n };\n}</code></pre>\n<p>Это учебный JavaScript-пример. Он не вызывает <code>fetch</code>, не имитирует задержку и не доказывает, что сервер принял запрос. Его задача — показать безопасный порядок проверки. Сначала сопоставляются идентификаторы. Потом сравниваются версии. Только после этого можно менять видимое состояние. В production нужно связать этот переход с реальным API, хранилищем и журналом событий.</p>\n<figure><img src=\"/assets/editorial/2022/poor-network-retry-contract-2022.svg\" alt=\"Схема разделения черновика, логического запроса, попыток и подтверждения при плохой сети\" loading=\"lazy\" /><figcaption>Схема показывает контракт retry: тот же логический запрос получает новую попытку, а acknowledgement проверяется по ключам и версии. Это иллюстрация состояний, не трасса реального сервиса.</figcaption></figure>\n<h2>Как читать симптомы</h2>\n<p>Не начинайте с увеличения таймаута. Сначала назовите наблюдаемый факт. «Пользователь видит успех без подтверждения» полезнее, чем «сеть нестабильна». «Старый ответ очищает новый текст» указывает на гонку версий. «Повтор создаёт две записи» требует проверки серверного контракта, а не только блокировки кнопки.</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>Зелёный success без ответа API</td><td>UI завершает операцию после окончания локального обработчика</td><td>Сопоставить место показа success с acknowledgement и ключами</td><td>Показывать ожидание до подтверждения; при отсутствии ответа — unknown outcome</td></tr><tr><td>После timeout черновик исчез</td><td>Очистка выполняется до подтверждения</td><td>Записать draft version до отправки и после ошибки транспорта</td><td>Оставлять persisted draft до принятия конкретной версии</td></tr><tr><td>Двойная запись после повторного клика</td><td>Retry создаёт новый логический запрос или сервер не распознаёт ключ</td><td>Сравнить request key у попыток и поведение API при повторе</td><td>Сохранить logical key; согласовать идемпотентность на сервере</td></tr><tr><td>Старый ответ стирает новый текст</td><td>Обработчик проверяет факт ответа, но не version</td><td>Дать первой попытке ответить после редактирования второй версии</td><td>Считать старый ack stale и сохранить новый draft</td></tr><tr><td>Offline блокирует отправку и удаляет текст</td><td>Состояние сети связано с очисткой формы</td><td>Проверить ветку без запуска transport</td><td>Оставить черновик; retry разрешать только после явного online-сигнала</td></tr></tbody></table></div>\n<h2>Порядок внедрения</h2>\n<ol><li>Опишите один сценарий: пользователь сохраняет конкретную версию текста, а не абстрактную форму.</li><li>Разделите в модели draft, request, attempt, acknowledgement и фазу UI. Не храните их в одном boolean.</li><li>Назначьте стабильный логический ключ и новый ключ каждой попытке. Запишите, как API обрабатывает повтор.</li><li>Добавьте ветку без acknowledgement. Она должна сохранить черновик и показать неизвестный результат.</li><li>Поставьте проверку ключей и версии перед каждым commit, очисткой формы и переходом в success.</li><li>Смоделируйте поздний ответ первой попытки после изменения черновика. Убедитесь, что новый текст остался.</li><li>Проведите отдельный browser/network сценарий на контролируемом окружении. Запишите условия, а не выдавайте учебный пример за production-результат.</li></ol>\n<h2>Что считать подтверждением</h2>\n<p>HTTP-ответ сам по себе не всегда равен предметному подтверждению. Статус показывает результат протокольного обмена, но прикладное действие может требовать идентификатора операции, принятой версии или чтения состояния после записи. Для простого черновика достаточно ответа с ключом запроса и версией. Для платежа, бронирования или выдачи права нужен более строгий контракт и отдельный путь проверки.</p>\n<p>Не путайте индикатор <code>navigator.onLine</code> с подтверждением. Он может подсказать, стоит ли планировать новую попытку, но не сообщает, обработан ли уже отправленный запрос. Service worker тоже не является гарантией доставки. Он может помочь с жизненным циклом фоновой работы, однако приложение всё равно должно описать persistence, повтор и подтверждение.</p>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Эта схема не решает конфликт двух вкладок, восстановление после удаления данных, безопасность локального черновика или согласование нескольких устройств. Она не делает неидемпотентный endpoint безопасным. Она также не определяет, что делать с чувствительным текстом после выхода пользователя. Эти решения требуют отдельной политики хранения, авторизации и серверного контракта.</p>\n<p>Бесконечный retry не является исправлением. Он может умножить операции, нагрузить API и скрыть неизвестный результат. Если сервер не принимает логический ключ, безопаснее оставить черновик и дать пользователю путь ручной проверки, чем обещать автоматическое восстановление. Если подтверждение пришло для старой версии, нельзя удалять новую работу только потому, что payload формально успешен.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Изменение готово, если на одном контролируемом сценарии можно показать четыре факта: отсутствие ответа не переводит UI в success; текст сохраняется после неизвестного результата; разрешённый retry сохраняет логический ключ; поздний acknowledgement старой версии не меняет новый черновик. Каждый факт должен быть виден в тесте, журнале переходов или воспроизводимом сценарии с названными условиями. Пока команда может доказать только «кнопка перестала крутиться», контракт не готов.</p>\n<p>Проверка должна завершаться не обещанием доступности сети, а наблюдаемым состоянием. Укажите версию черновика, ключ запроса, ключ попытки, phase и причину перехода. Не записывайте в диагностический журнал сам чувствительный текст. Если API не возвращает нужные данные, сначала измените контракт или честно оставьте состояние unknown.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://fetch.spec.whatwg.org/\" target=\"_blank\" rel=\"noopener noreferrer\">WHATWG Fetch Standard</a> — официальная спецификация моделей request, response и fetching; она не превращает таймаут клиента в доказательство результата предметной операции.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110: HTTP Semantics</a> — нормативное описание семантики HTTP-методов, статусов и повторов; прикладной ключ операции остаётся контрактом конкретного API.</li><li><a href=\"https://www.w3.org/TR/service-workers/\" target=\"_blank\" rel=\"noopener noreferrer\">W3C Service Workers</a> — официальная спецификация жизненного цикла service worker; наличие worker не заменяет правила persistence и acknowledgement.</li></ul>"
|
||
}
|