8 lines
19 KiB
JSON
8 lines
19 KiB
JSON
{
|
||
"index": 196,
|
||
"slug": "editorial-2022-07-field-poor-network",
|
||
"title": "Плохая сеть: как сохранить черновик и не перепутать результат запроса",
|
||
"excerpt": "При обрыве сети интерфейс не знает, дошла ли операция до сервера. Разделяем черновик, попытку и подтверждение, чтобы не потерять текст и не создать опасный повтор.",
|
||
"contentHtml": "<p>Пользователь нажимает «Сохранить», но ответ не приходит. Кнопка остаётся активной, поэтому он нажимает ещё раз. Через минуту появляется ошибка, а после обновления страницы новый текст исчезает. Иногда сервер всё же принял первую попытку, и повтор создаёт вторую операцию. Цена ошибки — потерянный черновик, дубль действия и спор с пользователем, которому интерфейс показал неверный результат.</p>\\n<p><strong>Тезис:</strong> плохая сеть требует не одного большего timeout, а явного контракта состояния. Интерфейс должен отдельно хранить версию черновика, логическую операцию и конкретную попытку доставки. Отсутствие ответа означает только «результат не подтверждён этим клиентом». Это не доказательство, что сервер ничего не сделал.</p>\\n<h2>Что именно ломается</h2>\\n<p>Обычная форма часто сводит всё к двум флагам: <code>isLoading</code> и <code>isSuccess</code>. Такая модель скрывает важное различие. Пользовательский текст может быть сохранён локально, запрос может быть отправлен, ответ может потеряться, а новый текст может появиться до прихода старого ответа. Один boolean не связывает эти события.</p>\\n<p>Нужно разделить три объекта. <code>draftVersion</code> обозначает содержимое, которое человек сейчас видит. <code>requestKey</code> обозначает одну логическую операцию, например «сохранить этот черновик». <code>attemptKey</code> обозначает конкретную передачу этой операции. При retry логическая операция может остаться прежней, а попытка должна измениться. Ответ обязан содержать или позволять сопоставить версию и операцию. Иначе поздний ответ может закрыть форму или очистить уже новый текст.</p>\\n<pre><code>type SubmitState = {\\n phase: 'idle' | 'awaiting-ack' | 'unknown' | 'acknowledged';\\n draftVersion: number;\\n requestKey: string | null;\\n attemptKey: string | null;\\n acknowledgedVersion: number | 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 { ...state, phase: 'acknowledged', acknowledgedVersion: ack.draftVersion };\\n}</code></pre>\\n<p>Код — учебный пример. Он не выполняет HTTP-запрос, не создаёт idempotency key на сервере и не заменяет интеграционный тест. Его задача — показать отрицательный путь: неподходящий ответ не меняет состояние. В рабочем приложении правила сопоставления должны совпадать с API-контрактом, а не жить только в компоненте.</p>\\n<h2>Симптом → причина → проверка → действие</h2>\\n<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>UI смешал ожидание и неизвестный результат</td><td>Проверить phase, requestKey, attemptKey и журнал ответа</td><td>Показать «результат неизвестен» и оставить черновик</td></tr><tr><td>Кнопка допускает второй клик</td><td>Нет ограничения повторной попытки</td><td>Сравнить число отправок с числом логических операций</td><td>Заблокировать обычный повтор до явного решения</td></tr><tr><td>Старый ответ очищает новый текст</td><td>Ответ проверяют только по времени прихода</td><td>Сопоставить draftVersion и requestKey перед commit</td><td>Игнорировать stale response</td></tr><tr><td>Ошибка говорит «не сохранено»</td><td>Transport error выдали за domain result</td><td>Установить, был ли application acknowledgement</td><td>Разделить «не подтверждено» и «отклонено»</td></tr><tr><td>Черновик пропадает после offline</td><td>Локальная копия создаётся после отправки</td><td>Проверить момент persistence и ошибку хранилища</td><td>Сохранять до отправки и показывать сбой persistence отдельно</td></tr></tbody></table>\\n<h2>Почему timeout не решает проблему</h2>\\n<p>Timeout ограничивает время ожидания клиента. Он не отменяет уже принятую сервером операцию и не сообщает, обработал ли её worker. Если клиент получил timeout, у него есть факт об отсутствии ответа в заданное время. У него нет факта о результате предметной операции.</p>\\n<p>Поэтому увеличивать timeout можно только после проверки причин. Длинное ожидание может ухудшить UX и увеличить число повторных кликов. Короткое ожидание может быстрее перевести форму в <code>unknown</code>, но это полезно только при сохранённом черновике и понятном маршруте восстановления. Transport-level ошибка и application-level отказ также различаются. Например, HTTP-ответ с ошибкой валидации сообщает, что сервер обработал запрос и отклонил данные. Обрыв соединения этого не сообщает.</p>\\n<p>HTTP-метод тоже не даёт полного решения. Идемпотентность метода — свойство семантики HTTP, но дубль предметной операции зависит от API. Если endpoint создаёт платёж, заказ или запись, серверу может понадобиться собственный ключ идемпотентности и политика хранения результата. Строка <code>attempt-02</code> в клиентской модели не становится такой защитой автоматически.</p>\\n<figure><img src=\"/assets/editorial/2022/poor-network-diagnosis-recovery-2022.svg\" alt=\"Схема формы при плохой сети: черновик сохраняется, попытка ожидает подтверждения, неизвестный результат ведёт к проверяемому восстановлению, а устаревший ответ не меняет новый текст\" loading=\"lazy\" /><figcaption>Схема показывает состояния пользовательского интерфейса. Она не является сетевой трассировкой и не доказывает, что конкретный сервер принял запрос.</figcaption></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>Если API поддерживает безопасный повтор, клиент отправляет ту же логическую операцию с новой попыткой и тем же согласованным ключом операции. Если API не описывает такую гарантию, UI не должен обещать «повторить безопасно». Он может сохранить черновик, предложить открыть историю или передать проверку оператору. Отрицательный путь — не дефект текста кнопки. Это часть контракта.</p>\\n<h2>Порядок действий</h2>\\n<ol><li><strong>Запишите наблюдение.</strong> Зафиксируйте, что видит пользователь: нет ответа, ошибка, дубль, исчезновение текста или поздний успех.</li><li><strong>Сохраните вход.</strong> Перед отправкой сохраните текущий draft и его версию. Отдельно обработайте ошибку локального хранилища.</li><li><strong>Назовите операцию.</strong> Создайте request key, связанный с предметным действием, и не меняйте его при обычном retry.</li><li><strong>Назовите попытку.</strong> Для каждой передачи создайте attempt key и запишите время, версию, endpoint и итог наблюдения.</li><li><strong>Разделите фазы.</strong> Используйте как минимум ожидание подтверждения, неизвестный результат, подтверждение и отказ. Не называйте неизвестное состояние успехом или неуспехом.</li><li><strong>Защитите commit.</strong> Перед применением ответа сравните ключ операции и версию черновика. Старый ответ не должен менять новую форму.</li><li><strong>Проверьте retry.</strong> Уточните у API-владельца, допускает ли операция повтор, каким ключом он защищён и сколько попыток разрешено.</li><li><strong>Проверьте реальный экран.</strong> Пройдите offline, медленное соединение, обрыв после отправки, поздний ответ и изменение текста во время ожидания.</li><li><strong>Сохраните отрицательный результат.</strong> Если подтверждение нельзя получить, оставьте черновик и объясните, что именно нужно проверить. Не удаляйте данные ради чистого UI.</li></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>В серверном логе нужен тот же контекст или явное правило корреляции. Проверьте, что обработчик отличает повтор того же ключа от новой операции. Убедитесь, что ответ содержит версию или другую проверяемую связь с payload. Если backend не возвращает такой контекст, frontend не сможет надёжно защититься от каждого stale response. Это ограничение нужно записать, а не скрывать дополнительным timeout.</p>\\n<p>Для локального теста достаточно нескольких переходов. Ввод версии 1 сохраняет копию. Offline блокирует новую попытку. Отсутствие acknowledgement переводит форму в unknown. Retry не стирает копию. Ответ версии 1 после ввода версии 2 не меняет текст. Эти тесты проверяют 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 механизм. Другой инженер должен воспроизвести эти переходы по логам и тесту без устного пояснения. Если хотя бы один ключ или отрицательный путь не назван, форму нельзя считать защищённой от плохой сети.</p>\\n<h2>Проверяемые источники</h2>\\n<ul><li><a href=\"https://fetch.spec.whatwg.org/\" target=\"_blank\" rel=\"noopener noreferrer\">WHATWG Fetch Standard</a> — официальный стандарт Fetch; описывает модель запроса, ответа и сетевой ошибки, но не подтверждает результат прикладной операции.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110: HTTP Semantics</a> — нормативное описание HTTP-семантики, включая свойства методов; прикладной ключ повторной операции и acknowledgement остаются контрактом конкретного API.</li></ul>"
|
||
}
|