8 lines
21 KiB
JSON
8 lines
21 KiB
JSON
{
|
||
"index": 196,
|
||
"slug": "editorial-2022-07-field-poor-network",
|
||
"title": "Плохая сеть: как сохранить черновик и не перепутать результат запроса",
|
||
"excerpt": "При обрыве сети интерфейс не знает, дошла ли операция до сервера. Разделяем черновик, попытку и подтверждение, чтобы не потерять текст и не создать опасный повтор.",
|
||
"contentHtml": "<p>Пользователь нажимает «Сохранить» в форме, но ответ не приходит. Кнопка остаётся активной, поэтому он нажимает ещё раз. Через минуту появляется ошибка, а после обновления страницы новый текст исчезает. Иногда сервер всё же принял первую попытку, и повтор создал вторую операцию. Цена ошибки — потерянный черновик, дубль действия и спор с пользователем, которому интерфейс показал неверный результат.</p>\n<p>Здесь недостаточно увеличить timeout. Нужен явный контракт состояния: отдельно хранить версию черновика, логическую операцию, попытки доставки и подтверждение сервера. Отсутствие ответа означает только «этот клиент не получил подтверждение». Это не доказательство, что сервер ничего не сделал.</p>\n<h2>Разделяем данные и события</h2>\n<p>Обычная форма часто сводит всё к двум флагам: <code>isLoading</code> и <code>isSuccess</code>. Такая модель скрывает порядок событий. Текст может быть сохранён локально, запрос может уйти, соединение может оборваться после отправки, а новый текст может появиться до старого ответа. Один boolean не связывает эти факты.</p>\n<p>Нужны как минимум четыре понятия. <code>draftVersion</code> обозначает содержимое, которое человек сейчас видит. <code>requestKey</code> обозначает одну логическую операцию, например «сохранить версию 7». <code>attemptKey</code> обозначает конкретную передачу этой операции. При retry логическая операция и её ключ остаются прежними, а попытка получает новый ключ. <code>phase</code> показывает, что система знает: она ждёт ответ, получила неизвестный результат, получила подтверждение или увидела отказ.</p>\n<p>Если пользователь изменил текст, версия становится 8. Поздний ответ для версии 7 не должен очищать форму. Для нового сохранения нужен отдельный логический контекст; старый ответ можно принять только после проверки версии и ключа операции.</p>\n<pre><code>const waiting = {\n phase: 'awaiting-ack',\n draftVersion: 7,\n requestKey: 'request-7',\n attemptKey: 'attempt-1',\n acknowledgedVersion: null,\n};\n\nfunction acceptAck(state, ack) {\n if (ack.requestKey !== state.requestKey) return state;\n if (ack.draftVersion !== state.draftVersion) return state;\n return {\n ...state,\n phase: 'acknowledged',\n acknowledgedVersion: ack.draftVersion,\n };\n}\n\nconst newerDraft = { ...waiting, draftVersion: 8 };\nconst staleAck = acceptAck(newerDraft, {\n requestKey: 'request-7',\n draftVersion: 7,\n});\n\nconsole.assert(staleAck.phase === 'awaiting-ack');</code></pre>\n<p>Это самостоятельная проверка отрицательного пути: acknowledgement старой версии не меняет состояние новой формы. В примере <code>attemptKey</code> нужен для журнала и диагностики. При повторе той же логической операции ответ от любой попытки можно сопоставить по <code>requestKey</code> и версии, если такой контракт задан API. В рабочем коде добавьте проверку формата ответа и тесты на смену фазы.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<table>\n<caption>Диагностика формы при неустойчивом соединении</caption>\n<thead>\n<tr>\n<th scope=\"col\">Симптом</th>\n<th scope=\"col\">Причина</th>\n<th scope=\"col\">Проверка</th>\n<th scope=\"col\">Действие</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>После клика нет ответа</td>\n<td>UI смешал ожидание и неизвестный результат</td>\n<td>Проверить <code>phase</code>, ключи и журнал ответа</td>\n<td>Показать «результат неизвестен» и оставить черновик</td>\n</tr>\n<tr>\n<td>Кнопка допускает второй клик</td>\n<td>Нет ограничения повторной отправки</td>\n<td>Сравнить число попыток с числом логических операций</td>\n<td>Заблокировать обычный повтор до явного решения</td>\n</tr>\n<tr>\n<td>Старый ответ очищает новый текст</td>\n<td>Ответ применяют только по времени прихода</td>\n<td>Сопоставить <code>draftVersion</code> и <code>requestKey</code></td>\n<td>Игнорировать устаревший ответ</td>\n</tr>\n<tr>\n<td>Ошибка говорит «не сохранено»</td>\n<td>Transport error выдали за domain result</td>\n<td>Установить, был ли получен application acknowledgement</td>\n<td>Разделить «не подтверждено» и «отклонено»</td>\n</tr>\n<tr>\n<td>Черновик пропадает после offline</td>\n<td>Локальная копия создаётся после отправки</td>\n<td>Проверить момент persistence и ошибку хранилища</td>\n<td>Сохранять до отправки и показывать сбой отдельно</td>\n</tr>\n</tbody>\n</table>\n<h2>Почему timeout и статус HTTP не дают полного ответа</h2>\n<p>Timeout ограничивает время ожидания клиента. Он не сообщает, успел ли сервер принять запрос и передать его обработчику. Если клиент прервал ожидание, у него есть факт об отсутствии ответа в заданное время, но нет факта о результате предметной операции. Поэтому нельзя превращать timeout в сообщение «операция отменена».</p>\n<p>В браузерном <code>fetch()</code> HTTP-ответ с кодом 4xx или 5xx сам по себе не равен сетевой ошибке: клиент получил объект <code>Response</code>. Признать такую операцию отклонённой можно только по контракту endpoint — например, по согласованному статусу и полю ошибки. Обрыв соединения даёт другую границу знания: response может не дойти, хотя запрос уже был принят.</p>\n<p>HTTP-метод тоже не даёт универсального разрешения на retry. Для идемпотентного метода повтор одинакового запроса должен иметь тот же предполагаемый эффект, но это не отменяет побочных записей в лог или истории. Для неидемпотентного метода клиенту нужен явно согласованный механизм: ключ операции, проверка результата, безопасный endpoint или ручная сверка. Строка <code>attempt-02</code> в клиентской модели не становится защитой от дубля автоматически.</p>\n<figure>\n<img src=\"/assets/editorial/2022/poor-network-diagnosis-recovery-2022.svg\" alt=\"Схема формы при плохой сети: черновик сохраняется, попытка ожидает подтверждения, неизвестный результат ведёт к проверяемому восстановлению, а устаревший ответ не меняет новый текст\" loading=\"lazy\" />\n<figcaption>Схема показывает состояния пользовательского интерфейса и точки проверки. Она не является сетевой трассировкой и не доказывает, что конкретный сервер принял запрос.</figcaption>\n</figure>\n<h2>Учебный сценарий с поздним ответом</h2>\n<p>Рассмотрим форму с текстом «Вернуть черновик без потери». До отправки приложение записывает локальную копию с версией 7. Затем создаёт <code>request-7</code> и попытку <code>attempt-1</code>. Соединение обрывается после отправки. Клиент переводит форму в <code>unknown</code>, не удаляет копию и показывает два разных действия: проверить результат или повторить по правилам API.</p>\n<p>Если пользователь изменил текст, версия становится 8. Поздний ответ для версии 7 больше не может очистить экран: reducer сравнивает версию перед commit. Это важно не только при плохой сети. Ответ старой попытки может прийти после нового ввода и при обычном соединении.</p>\n<p>Допустимы четыре разных наблюдения. Подтверждение с совпавшими ключом и версией переводит форму в <code>acknowledged</code>. Ответ с ошибкой валидации переводит её в <code>rejected</code>, если это предусмотрено API. Ответ старой версии игнорируется. Отсутствие ответа оставляет <code>unknown</code> и ведёт к сверке через предусмотренный endpoint, историю операции или оператора. Последний маршрут нельзя выдумывать на уровне UI.</p>\n<p>Если API поддерживает безопасный повтор, клиент отправляет ту же логическую операцию с новой попыткой и тем же согласованным ключом. Если API не описывает такую гарантию, UI не должен обещать «повторить безопасно». Он может сохранить черновик, предложить проверить историю или передать сверку оператору. Неизвестный результат — часть контракта, а не неудачный текст кнопки.</p>\n<h2>Порядок действий</h2>\n<ol>\n<li><strong>Зафиксируйте наблюдение.</strong> Запишите, что видит пользователь: нет ответа, ошибка, дубль, исчезновение текста или поздний успех.</li>\n<li><strong>Сохраните вход.</strong> Перед отправкой сохраните текущий draft и его версию. Ошибку локального хранилища обработайте отдельно от сетевой ошибки.</li>\n<li><strong>Назовите операцию.</strong> Создайте ключ, связанный с предметным действием и версией данных. Не меняйте его при retry той же операции.</li>\n<li><strong>Назовите попытку.</strong> Для каждой передачи создайте новый ключ и запишите время, версию, endpoint и итог наблюдения.</li>\n<li><strong>Разделите фазы.</strong> Используйте как минимум ожидание подтверждения, неизвестный результат, подтверждение и отказ. Не называйте неизвестное состояние успехом или неуспехом.</li>\n<li><strong>Защитите commit.</strong> Перед применением ответа сравните ключ операции и версию черновика. Старый ответ не должен менять новую форму.</li>\n<li><strong>Проверьте retry.</strong> Уточните у API-владельца, допускает ли операция повтор, каким ключом он защищён и сколько попыток разрешено.</li>\n<li><strong>Проверьте экран.</strong> Пройдите offline, медленное соединение, обрыв после отправки, поздний ответ и изменение текста во время ожидания.</li>\n<li><strong>Сохраните отрицательный результат.</strong> Если подтверждение нельзя получить, оставьте черновик и объясните, что именно нужно проверить. Не удаляйте данные ради чистого UI.</li>\n</ol>\n<h2>Что проверять в коде и логах</h2>\n<p>В клиентском логе каждая отправка должна иметь request key, attempt key, draft version и phase. Логируйте переходы, а не только exception. Полезная последовательность выглядит так: <code>draft-v7 → awaiting-ack → unknown → retry-attempt-2 → acknowledged-v7</code>. Если вместо неё видна только строка «save failed», расследование снова начнётся с догадки.</p>\n<p>На сервере нужен тот же контекст или явное правило корреляции. Проверьте, что обработчик отличает повтор того же ключа от новой операции. Убедитесь, что response содержит версию или другую проверяемую связь с payload. Если backend не возвращает такой контекст, frontend не сможет надёжно защититься от каждого stale response. Это ограничение нужно записать, а не скрывать дополнительным timeout.</p>\n<p>Для локального теста составьте пять проверяемых переходов. Ввод версии 1 сохраняет копию. Offline блокирует новую попытку. Отсутствие acknowledgement переводит форму в unknown. Retry не стирает копию. Ответ версии 1 после ввода версии 2 не меняет текст. Отдельно проверьте, что подтверждение текущей версии меняет фазу, а HTTP-ошибка становится отказом только по контракту API. Эти тесты проверяют state machine, но не доказывают качество реальной сети или доступность сервера.</p>\n<h2>Ограничения и безопасный отрицательный путь</h2>\n<p>Статья не выбирает конкретный HTTP-метод, не проектирует очередь, не обещает фоновой доставки и не вводит серверную идемпотентность. Service worker, IndexedDB, фоновые sync-механизмы и локальное шифрование имеют собственные жизненные циклы и ошибки. Их нельзя добавить как синоним надёжности. Сначала нужен контракт результата, затем конкретная реализация persistence или retry.</p>\n<p>Модель также не решает конфликт двух вкладок, вход с другого устройства, истёкшую авторизацию, изменение схемы API и ручное исправление уже созданной операции. Если эти случаи возможны, состояние <code>unknown</code> должно вести к отдельному процессу проверки. Нельзя автоматически повторять перевод, заказ или платёж только потому, что UI не увидел ответ.</p>\n<p>Если локальное сохранение не удалось, безопасный путь отличается от сетевого. Не показывайте «сохранено на устройстве». Оставьте текст в памяти только до понятного предупреждения, предложите копирование или остановите отправку. Если подтверждение сервера неизвестно, не называйте операцию отменённой. Сначала сохраните данные пользователя и покажите границу знания системы.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Форма готова к следующему инженерному шагу, когда для одного сценария можно ответить на пять вопросов: какая версия текста отправлена, какая логическая операция выполняется, какая попытка дала ответ, что именно подтверждает сервер и что увидит человек при отсутствии подтверждения.</p>\n<p>Проверка считается пройденной, если тест с поздним ответом не меняет новый черновик, offline не удаляет сохранённую копию, неизвестный результат не становится успехом, а повтор использует согласованный с API механизм. Другой инженер должен воспроизвести эти переходы по логу, сетевому mock и тесту без устного пояснения. Если хотя бы один ключ или отрицательный путь не назван, форму нельзя считать защищённой от плохой сети.</p>\n<h2>Проверяемые источники</h2>\n<ul>\n<li><a href=\"https://fetch.spec.whatwg.org/#concept-network-error\" target=\"_blank\" rel=\"noopener noreferrer\">WHATWG Fetch Standard: network error и fetch</a> — официальный стандарт описывает запросы, ответы и сетевую ошибку; полученный HTTP-ответ и результат прикладной операции нужно различать.</li>\n<li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110 §9.2.2: Idempotent Methods</a> — нормативно определяет идемпотентность и осторожный retry при communication failure; ключ операции и acknowledgement остаются контрактом конкретного API.</li>\n</ul>"
|
||
}
|